mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-18 20:35:24 +02:00
Compare commits
1 Commits
v0.25.7
...
2234-datab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c81968e9 |
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)
|
## 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)
|
## 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 |
2
.github/workflows/dokploy.yml
vendored
2
.github/workflows/dokploy.yml
vendored
@@ -2,7 +2,7 @@ name: Dokploy Docker Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, canary, "fix/re-apply-database-migration-fix"]
|
branches: [main, canary]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -11,25 +11,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<br />
|
<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.
|
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
||||||
|
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
Dokploy includes multiple features to make your life easier.
|
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>
|
<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.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.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>
|
</div>
|
||||||
|
|
||||||
<!-- Premium Supporters 🥇 -->
|
<!-- Premium Supporters 🥇 -->
|
||||||
|
|||||||
@@ -5,11 +5,7 @@ import { zValidator } from "@hono/zod-validator";
|
|||||||
import { Inngest } from "inngest";
|
import { Inngest } from "inngest";
|
||||||
import { serve as serveInngest } from "inngest/hono";
|
import { serve as serveInngest } from "inngest/hono";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
import {
|
import { type DeployJob, deployJobSchema } from "./schema.js";
|
||||||
cancelDeploymentSchema,
|
|
||||||
type DeployJob,
|
|
||||||
deployJobSchema,
|
|
||||||
} from "./schema.js";
|
|
||||||
import { deploy } from "./utils.js";
|
import { deploy } from "./utils.js";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -31,13 +27,6 @@ export const deploymentFunction = inngest.createFunction(
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
retries: 0,
|
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" },
|
{ 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) => {
|
app.get("/health", async (c) => {
|
||||||
return c.json({ status: "ok" });
|
return c.json({ status: "ok" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { z } from "zod";
|
|||||||
export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
||||||
z.object({
|
z.object({
|
||||||
applicationId: z.string(),
|
applicationId: z.string(),
|
||||||
titleLog: z.string().optional(),
|
titleLog: z.string(),
|
||||||
descriptionLog: z.string().optional(),
|
descriptionLog: z.string(),
|
||||||
server: z.boolean().optional(),
|
server: z.boolean().optional(),
|
||||||
type: z.enum(["deploy", "redeploy"]),
|
type: z.enum(["deploy", "redeploy"]),
|
||||||
applicationType: z.literal("application"),
|
applicationType: z.literal("application"),
|
||||||
@@ -12,8 +12,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
composeId: z.string(),
|
composeId: z.string(),
|
||||||
titleLog: z.string().optional(),
|
titleLog: z.string(),
|
||||||
descriptionLog: z.string().optional(),
|
descriptionLog: z.string(),
|
||||||
server: z.boolean().optional(),
|
server: z.boolean().optional(),
|
||||||
type: z.enum(["deploy", "redeploy"]),
|
type: z.enum(["deploy", "redeploy"]),
|
||||||
applicationType: z.literal("compose"),
|
applicationType: z.literal("compose"),
|
||||||
@@ -22,8 +22,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
|||||||
z.object({
|
z.object({
|
||||||
applicationId: z.string(),
|
applicationId: z.string(),
|
||||||
previewDeploymentId: z.string(),
|
previewDeploymentId: z.string(),
|
||||||
titleLog: z.string().optional(),
|
titleLog: z.string(),
|
||||||
descriptionLog: z.string().optional(),
|
descriptionLog: z.string(),
|
||||||
server: z.boolean().optional(),
|
server: z.boolean().optional(),
|
||||||
type: z.enum(["deploy"]),
|
type: z.enum(["deploy"]),
|
||||||
applicationType: z.literal("application-preview"),
|
applicationType: z.literal("application-preview"),
|
||||||
@@ -32,16 +32,3 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export type DeployJob = z.infer<typeof deployJobSchema>;
|
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 {
|
import {
|
||||||
deployApplication,
|
deployRemoteApplication,
|
||||||
deployCompose,
|
deployRemoteCompose,
|
||||||
deployPreviewApplication,
|
deployRemotePreviewApplication,
|
||||||
rebuildApplication,
|
rebuildRemoteApplication,
|
||||||
rebuildCompose,
|
rebuildRemoteCompose,
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -16,16 +16,16 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
await updateApplicationStatus(job.applicationId, "running");
|
await updateApplicationStatus(job.applicationId, "running");
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "redeploy") {
|
if (job.type === "redeploy") {
|
||||||
await rebuildApplication({
|
await rebuildRemoteApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Rebuild deployment",
|
titleLog: job.titleLog,
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog,
|
||||||
});
|
});
|
||||||
} else if (job.type === "deploy") {
|
} else if (job.type === "deploy") {
|
||||||
await deployApplication({
|
await deployRemoteApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Manual deployment",
|
titleLog: job.titleLog,
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -36,16 +36,16 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
|
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "redeploy") {
|
if (job.type === "redeploy") {
|
||||||
await rebuildCompose({
|
await rebuildRemoteCompose({
|
||||||
composeId: job.composeId,
|
composeId: job.composeId,
|
||||||
titleLog: job.titleLog || "Rebuild deployment",
|
titleLog: job.titleLog,
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog,
|
||||||
});
|
});
|
||||||
} else if (job.type === "deploy") {
|
} else if (job.type === "deploy") {
|
||||||
await deployCompose({
|
await deployRemoteCompose({
|
||||||
composeId: job.composeId,
|
composeId: job.composeId,
|
||||||
titleLog: job.titleLog || "Manual deployment",
|
titleLog: job.titleLog,
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,10 +55,10 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
});
|
});
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "deploy") {
|
if (job.type === "deploy") {
|
||||||
await deployPreviewApplication({
|
await deployRemotePreviewApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Preview Deployment",
|
titleLog: job.titleLog,
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog,
|
||||||
previewDeploymentId: job.previewDeploymentId,
|
previewDeploymentId: job.previewDeploymentId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToAllProperties } from "@dokploy/server";
|
import { addSuffixToAllProperties } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile1 = `
|
const composeFile1 = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -61,7 +61,7 @@ secrets:
|
|||||||
file: ./db_password.txt
|
file: ./db_password.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile1 = parse(`
|
const expectedComposeFile1 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -120,7 +120,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all properties in compose file 1", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||||
@@ -185,7 +185,7 @@ secrets:
|
|||||||
file: ./db_password.txt
|
file: ./db_password.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile2 = parse(`
|
const expectedComposeFile2 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -243,7 +243,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all properties in compose file 2", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||||
@@ -308,7 +308,7 @@ secrets:
|
|||||||
file: ./service_secret.txt
|
file: ./service_secret.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile3 = parse(`
|
const expectedComposeFile3 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -366,7 +366,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all properties in compose file 3", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||||
@@ -420,7 +420,7 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile = parse(`
|
const expectedComposeFile = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -467,7 +467,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all properties in Plausible compose file", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -23,7 +23,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to configs in root property", () => {
|
test("Add suffix to configs in root property", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to multiple configs in root property", () => {
|
test("Add suffix to multiple configs in root property", () => {
|
||||||
const composeData = parse(composeFileMultipleConfigs) as ComposeSpecification;
|
const composeData = load(composeFileMultipleConfigs) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to configs with different properties in root property", () => {
|
test("Add suffix to configs with different properties in root property", () => {
|
||||||
const composeData = parse(
|
const composeData = load(
|
||||||
composeFileDifferentProperties,
|
composeFileDifferentProperties,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Expected compose file con el prefijo `testhash`
|
// Expected compose file con el prefijo `testhash`
|
||||||
const expectedComposeFileConfigRoot = parse(`
|
const expectedComposeFileConfigRoot = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -162,7 +162,7 @@ configs:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to configs in root property", () => {
|
test("Add suffix to configs in root property", () => {
|
||||||
const composeData = parse(composeFileConfigRoot) as ComposeSpecification;
|
const composeData = load(composeFileConfigRoot) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
addSuffixToConfigsInServices,
|
addSuffixToConfigsInServices,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -22,7 +22,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to configs in services", () => {
|
test("Add suffix to configs in services", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to configs in services with single config", () => {
|
test("Add suffix to configs in services with single config", () => {
|
||||||
const composeData = parse(
|
const composeData = load(
|
||||||
composeFileSingleServiceConfig,
|
composeFileSingleServiceConfig,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to configs in services with multiple configs", () => {
|
test("Add suffix to configs in services with multiple configs", () => {
|
||||||
const composeData = parse(
|
const composeData = load(
|
||||||
composeFileMultipleServicesConfigs,
|
composeFileMultipleServicesConfigs,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ services:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Expected compose file con el prefijo `testhash`
|
// Expected compose file con el prefijo `testhash`
|
||||||
const expectedComposeFileConfigServices = parse(`
|
const expectedComposeFileConfigServices = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -182,7 +182,7 @@ services:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to configs in services", () => {
|
test("Add suffix to configs in services", () => {
|
||||||
const composeData = parse(composeFileConfigServices) as ComposeSpecification;
|
const composeData = load(composeFileConfigServices) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -43,7 +43,7 @@ configs:
|
|||||||
file: ./db-config.yml
|
file: ./db-config.yml
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileCombinedConfigs = parse(`
|
const expectedComposeFileCombinedConfigs = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -77,7 +77,7 @@ configs:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all configs in root and services", () => {
|
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";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ configs:
|
|||||||
file: ./db-config.yml
|
file: ./db-config.yml
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileWithEnvAndExternal = parse(`
|
const expectedComposeFileWithEnvAndExternal = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -159,7 +159,7 @@ configs:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to configs with environment and external", () => {
|
test("Add suffix to configs with environment and external", () => {
|
||||||
const composeData = parse(
|
const composeData = load(
|
||||||
composeFileWithEnvAndExternal,
|
composeFileWithEnvAndExternal,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ configs:
|
|||||||
file: ./db-config.yml
|
file: ./db-config.yml
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileWithTemplateDriverAndLabels = parse(`
|
const expectedComposeFileWithTemplateDriverAndLabels = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -231,7 +231,7 @@ configs:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to configs with template driver and labels", () => {
|
test("Add suffix to configs with template driver and labels", () => {
|
||||||
const composeData = parse(
|
const composeData = load(
|
||||||
composeFileWithTemplateDriverAndLabels,
|
composeFileWithTemplateDriverAndLabels,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -35,7 +35,7 @@ test("Generate random hash with 8 characters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Add suffix to networks root property", () => {
|
test("Add suffix to networks root property", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to advanced networks root property (2 TRY)", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks with external properties", () => {
|
test("Add suffix to networks with external properties", () => {
|
||||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
const composeData = load(composeFile3) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks with IPAM configurations", () => {
|
test("Add suffix to networks with IPAM configurations", () => {
|
||||||
const composeData = parse(composeFile4) as ComposeSpecification;
|
const composeData = load(composeFile4) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks with custom options", () => {
|
test("Add suffix to networks with custom options", () => {
|
||||||
const composeData = parse(composeFile5) as ComposeSpecification;
|
const composeData = load(composeFile5) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks with static suffix", () => {
|
test("Add suffix to networks with static suffix", () => {
|
||||||
const composeData = parse(composeFile6) as ComposeSpecification;
|
const composeData = load(composeFile6) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ test("Add suffix to networks with static suffix", () => {
|
|||||||
}
|
}
|
||||||
const networks = addSuffixToNetworksRoot(composeData.networks, suffix);
|
const networks = addSuffixToNetworksRoot(composeData.networks, suffix);
|
||||||
|
|
||||||
const expectedComposeData = parse(
|
const expectedComposeData = load(
|
||||||
expectedComposeFile6,
|
expectedComposeFile6,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
expect(networks).toStrictEqual(expectedComposeData.networks);
|
expect(networks).toStrictEqual(expectedComposeData.networks);
|
||||||
@@ -293,7 +293,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shoudn't add suffix to dokploy-network", () => {
|
test("It shoudn't add suffix to dokploy-network", () => {
|
||||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
const composeData = load(composeFile7) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
addSuffixToServiceNetworks,
|
addSuffixToServiceNetworks,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks in services", () => {
|
test("Add suffix to networks in services", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks in services with aliases", () => {
|
test("Add suffix to networks in services with aliases", () => {
|
||||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
const composeData = load(composeFile2) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks in services (Object with simple 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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks in services (combined case)", () => {
|
test("Add suffix to networks in services (combined case)", () => {
|
||||||
const composeData = parse(composeFileCombined) as ComposeSpecification;
|
const composeData = load(composeFileCombined) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ services:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shoudn't add suffix to dokploy-network in 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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ services:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
addSuffixToServiceNetworks,
|
addSuffixToServiceNetworks,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFileCombined = `
|
const composeFileCombined = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -39,7 +39,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks in services and root (combined case)", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ test("Add suffix to networks in services and root (combined case)", () => {
|
|||||||
expect(redisNetworks).not.toHaveProperty("backend");
|
expect(redisNetworks).not.toHaveProperty("backend");
|
||||||
});
|
});
|
||||||
|
|
||||||
const expectedComposeFile = parse(`
|
const expectedComposeFile = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -120,7 +120,7 @@ networks:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
test("Add suffix to networks in compose file", () => {
|
test("Add suffix to networks in compose file", () => {
|
||||||
const composeData = parse(composeFileCombined) as ComposeSpecification;
|
const composeData = load(composeFileCombined) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
if (!composeData?.networks) {
|
if (!composeData?.networks) {
|
||||||
@@ -156,7 +156,7 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile2 = parse(`
|
const expectedComposeFile2 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -182,7 +182,7 @@ networks:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
test("Add suffix to networks in compose file with external and internal 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 suffix = "testhash";
|
||||||
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
||||||
@@ -218,7 +218,7 @@ networks:
|
|||||||
com.docker.network.bridge.enable_icc: "true"
|
com.docker.network.bridge.enable_icc: "true"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile3 = parse(`
|
const expectedComposeFile3 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -247,7 +247,7 @@ networks:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
test("Add suffix to networks in compose file with multiple services and complex network configurations", () => {
|
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 suffix = "testhash";
|
||||||
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
||||||
@@ -289,7 +289,7 @@ networks:
|
|||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile4 = parse(`
|
const expectedComposeFile4 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
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", () => {
|
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 suffix = "testhash";
|
||||||
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -23,7 +23,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in root property", () => {
|
test("Add suffix to secrets in root property", () => {
|
||||||
const composeData = parse(composeFileSecretsRoot) as ComposeSpecification;
|
const composeData = load(composeFileSecretsRoot) as ComposeSpecification;
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData?.secrets) {
|
if (!composeData?.secrets) {
|
||||||
@@ -52,7 +52,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in root property (Test 1)", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData?.secrets) {
|
if (!composeData?.secrets) {
|
||||||
@@ -84,7 +84,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in root property (Test 2)", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData?.secrets) {
|
if (!composeData?.secrets) {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
addSuffixToSecretsInServices,
|
addSuffixToSecretsInServices,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFileSecretsServices = `
|
const composeFileSecretsServices = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -21,7 +21,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in services", () => {
|
test("Add suffix to secrets in services", () => {
|
||||||
const composeData = parse(composeFileSecretsServices) as ComposeSpecification;
|
const composeData = load(composeFileSecretsServices) as ComposeSpecification;
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData.services) {
|
if (!composeData.services) {
|
||||||
@@ -54,9 +54,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in services (Test 1)", () => {
|
test("Add suffix to secrets in services (Test 1)", () => {
|
||||||
const composeData = parse(
|
const composeData = load(composeFileSecretsServices1) as ComposeSpecification;
|
||||||
composeFileSecretsServices1,
|
|
||||||
) as ComposeSpecification;
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData.services) {
|
if (!composeData.services) {
|
||||||
@@ -95,9 +93,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in services (Test 2)", () => {
|
test("Add suffix to secrets in services (Test 2)", () => {
|
||||||
const composeData = parse(
|
const composeData = load(composeFileSecretsServices2) as ComposeSpecification;
|
||||||
composeFileSecretsServices2,
|
|
||||||
) as ComposeSpecification;
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData.services) {
|
if (!composeData.services) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToAllSecrets } from "@dokploy/server";
|
import { addSuffixToAllSecrets } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFileCombinedSecrets = `
|
const composeFileCombinedSecrets = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -25,7 +25,7 @@ secrets:
|
|||||||
file: ./app_secret.txt
|
file: ./app_secret.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileCombinedSecrets = parse(`
|
const expectedComposeFileCombinedSecrets = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -48,7 +48,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all secrets", () => {
|
test("Add suffix to all secrets", () => {
|
||||||
const composeData = parse(composeFileCombinedSecrets) as ComposeSpecification;
|
const composeData = load(composeFileCombinedSecrets) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
||||||
@@ -77,7 +77,7 @@ secrets:
|
|||||||
file: ./cache_secret.txt
|
file: ./cache_secret.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileCombinedSecrets3 = parse(`
|
const expectedComposeFileCombinedSecrets3 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -99,9 +99,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all secrets (3rd Case)", () => {
|
test("Add suffix to all secrets (3rd Case)", () => {
|
||||||
const composeData = parse(
|
const composeData = load(composeFileCombinedSecrets3) as ComposeSpecification;
|
||||||
composeFileCombinedSecrets3,
|
|
||||||
) as ComposeSpecification;
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
||||||
@@ -130,7 +128,7 @@ secrets:
|
|||||||
file: ./db_password.txt
|
file: ./db_password.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileCombinedSecrets4 = parse(`
|
const expectedComposeFileCombinedSecrets4 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -152,9 +150,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all secrets (4th Case)", () => {
|
test("Add suffix to all secrets (4th Case)", () => {
|
||||||
const composeData = parse(
|
const composeData = load(composeFileCombinedSecrets4) as ComposeSpecification;
|
||||||
composeFileCombinedSecrets4,
|
|
||||||
) as ComposeSpecification;
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
version: "3.8"
|
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", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -32,7 +32,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with depends_on (array) in compose file", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with depends_on (object) in compose file", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -30,7 +30,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with extends (string) in compose file", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with extends (object) in compose file", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -31,7 +31,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with links in compose file", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -26,7 +26,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names in compose file", () => {
|
test("Add suffix to service names in compose file", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
addSuffixToAllServiceNames,
|
addSuffixToAllServiceNames,
|
||||||
addSuffixToServiceNames,
|
addSuffixToServiceNames,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFileCombinedAllCases = `
|
const composeFileCombinedAllCases = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -38,7 +38,7 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile = parse(`
|
const expectedComposeFile = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -71,9 +71,7 @@ networks:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
test("Add suffix to all service names in compose file", () => {
|
test("Add suffix to all service names in compose file", () => {
|
||||||
const composeData = parse(
|
const composeData = load(composeFileCombinedAllCases) as ComposeSpecification;
|
||||||
composeFileCombinedAllCases,
|
|
||||||
) as ComposeSpecification;
|
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -133,7 +131,7 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile1 = parse(`
|
const expectedComposeFile1 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -178,7 +176,7 @@ networks:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all service names in compose file 1", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
||||||
@@ -229,7 +227,7 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile2 = parse(`
|
const expectedComposeFile2 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -273,7 +271,7 @@ networks:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all service names in compose file 2", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
||||||
@@ -324,7 +322,7 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile3 = parse(`
|
const expectedComposeFile3 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -368,7 +366,7 @@ networks:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all service names in compose file 3", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -35,7 +35,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with volumes_from in compose file", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
addSuffixToVolumesRoot,
|
addSuffixToVolumesRoot,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
services:
|
services:
|
||||||
@@ -70,7 +70,7 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerCompose = parse(`
|
const expectedDockerCompose = load(`
|
||||||
services:
|
services:
|
||||||
mail:
|
mail:
|
||||||
image: bytemark/smtp
|
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
|
// 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
|
// 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", () => {
|
test("Add suffix to volumes root property", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
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", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
@@ -195,7 +195,7 @@ volumes:
|
|||||||
mongo-data:
|
mongo-data:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerCompose2 = parse(`
|
const expectedDockerCompose2 = load(`
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
@@ -218,7 +218,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Expect to change the suffix in all the possible places (2 Try)", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
@@ -248,7 +248,7 @@ volumes:
|
|||||||
mongo-data:
|
mongo-data:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerCompose3 = parse(`
|
const expectedDockerCompose3 = load(`
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
@@ -271,7 +271,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Expect to change the suffix in all the possible places (3 Try)", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
@@ -645,7 +645,7 @@ volumes:
|
|||||||
db-config:
|
db-config:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerComposeComplex = parse(`
|
const expectedDockerComposeComplex = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
services:
|
services:
|
||||||
studio:
|
studio:
|
||||||
@@ -1012,7 +1012,7 @@ volumes:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
test("Expect to change the suffix in all the possible places (4 Try)", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
@@ -1065,7 +1065,7 @@ volumes:
|
|||||||
db-data:
|
db-data:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerComposeExample1 = parse(`
|
const expectedDockerComposeExample1 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
@@ -1111,7 +1111,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Expect to change the suffix in all the possible places (5 Try)", () => {
|
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 suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
@@ -1143,7 +1143,7 @@ volumes:
|
|||||||
backrest-cache:
|
backrest-cache:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerComposeBackrest = parse(`
|
const expectedDockerComposeBackrest = load(`
|
||||||
services:
|
services:
|
||||||
backrest:
|
backrest:
|
||||||
image: garethgeorge/backrest:v1.7.3
|
image: garethgeorge/backrest:v1.7.3
|
||||||
@@ -1168,7 +1168,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Should handle volume paths with subdirectories correctly", () => {
|
test("Should handle volume paths with subdirectories correctly", () => {
|
||||||
const composeData = parse(composeFileBackrest) as ComposeSpecification;
|
const composeData = load(composeFileBackrest) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -29,7 +29,7 @@ test("Generate random hash with 8 characters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Add suffix to volumes in root property", () => {
|
test("Add suffix to volumes in root property", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to volumes in root property (Case 2)", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to volumes in root property (Case 3)", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ volumes:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Expected compose file con el prefijo `testhash`
|
// Expected compose file con el prefijo `testhash`
|
||||||
const expectedComposeFile4 = parse(`
|
const expectedComposeFile4 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -179,7 +179,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to volumes in root property", () => {
|
test("Add suffix to volumes in root property", () => {
|
||||||
const composeData = parse(composeFile4) as ComposeSpecification;
|
const composeData = load(composeFile4) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
addSuffixToVolumesInServices,
|
addSuffixToVolumesInServices,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to volumes declared directly in 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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ volumes:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to volumes declared directly in services (Case 2)", () => {
|
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();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToAllVolumes } from "@dokploy/server";
|
import { addSuffixToAllVolumes } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFileTypeVolume = `
|
const composeFileTypeVolume = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -23,7 +23,7 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileTypeVolume = parse(`
|
const expectedComposeFileTypeVolume = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -44,7 +44,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to volumes with type: volume in services", () => {
|
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";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileTypeVolume1 = parse(`
|
const expectedComposeFileTypeVolume1 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -93,7 +93,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to mixed volumes in services", () => {
|
test("Add suffix to mixed volumes in services", () => {
|
||||||
const composeData = parse(composeFileTypeVolume1) as ComposeSpecification;
|
const composeData = load(composeFileTypeVolume1) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ volumes:
|
|||||||
device: /path/to/app/logs
|
device: /path/to/app/logs
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileTypeVolume2 = parse(`
|
const expectedComposeFileTypeVolume2 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -154,7 +154,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to complex volume configurations in services", () => {
|
test("Add suffix to complex volume configurations in services", () => {
|
||||||
const composeData = parse(composeFileTypeVolume2) as ComposeSpecification;
|
const composeData = load(composeFileTypeVolume2) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ volumes:
|
|||||||
device: /path/to/shared/logs
|
device: /path/to/shared/logs
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileTypeVolume3 = parse(`
|
const expectedComposeFileTypeVolume3 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -273,7 +273,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to complex nested volumes configuration in services", () => {
|
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";
|
const suffix = "testhash";
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
|
||||||
extractCommitMessage,
|
|
||||||
extractImageName,
|
|
||||||
extractImageTag,
|
|
||||||
extractImageTagFromRequest,
|
|
||||||
} from "@/pages/api/deploy/[refreshToken]";
|
|
||||||
|
|
||||||
describe("GitHub Webhook Skip CI", () => {
|
describe("GitHub Webhook Skip CI", () => {
|
||||||
const mockGithubHeaders = {
|
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -42,14 +42,12 @@ const baseApp: ApplicationNested = {
|
|||||||
triggerType: "push",
|
triggerType: "push",
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
endpointSpecSwarm: null,
|
|
||||||
serverId: "",
|
serverId: "",
|
||||||
registryUrl: "",
|
registryUrl: "",
|
||||||
branch: null,
|
branch: null,
|
||||||
dockerBuildStage: "",
|
dockerBuildStage: "",
|
||||||
isPreviewDeploymentsActive: false,
|
isPreviewDeploymentsActive: false,
|
||||||
previewBuildArgs: null,
|
previewBuildArgs: null,
|
||||||
previewBuildSecrets: null,
|
|
||||||
previewCertificateType: "none",
|
previewCertificateType: "none",
|
||||||
previewCustomCertResolver: null,
|
previewCustomCertResolver: null,
|
||||||
previewEnv: null,
|
previewEnv: null,
|
||||||
@@ -58,24 +56,15 @@ const baseApp: ApplicationNested = {
|
|||||||
previewPort: 3000,
|
previewPort: 3000,
|
||||||
previewLimit: 0,
|
previewLimit: 0,
|
||||||
previewWildcard: "",
|
previewWildcard: "",
|
||||||
environment: {
|
project: {
|
||||||
env: "",
|
env: "",
|
||||||
environmentId: "",
|
organizationId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
|
||||||
description: "",
|
description: "",
|
||||||
|
createdAt: "",
|
||||||
projectId: "",
|
projectId: "",
|
||||||
project: {
|
|
||||||
env: "",
|
|
||||||
organizationId: "",
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
createdAt: "",
|
|
||||||
projectId: "",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
buildArgs: null,
|
buildArgs: null,
|
||||||
buildSecrets: null,
|
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
gitlabPathNamespace: "",
|
gitlabPathNamespace: "",
|
||||||
buildType: "nixpacks",
|
buildType: "nixpacks",
|
||||||
@@ -103,7 +92,6 @@ const baseApp: ApplicationNested = {
|
|||||||
dockerfile: null,
|
dockerfile: null,
|
||||||
dockerImage: null,
|
dockerImage: null,
|
||||||
dropBuildPath: null,
|
dropBuildPath: null,
|
||||||
environmentId: "",
|
|
||||||
enabled: null,
|
enabled: null,
|
||||||
env: null,
|
env: null,
|
||||||
healthCheckSwarm: null,
|
healthCheckSwarm: null,
|
||||||
@@ -118,6 +106,7 @@ const baseApp: ApplicationNested = {
|
|||||||
password: null,
|
password: null,
|
||||||
placementSwarm: null,
|
placementSwarm: null,
|
||||||
ports: [],
|
ports: [],
|
||||||
|
projectId: "",
|
||||||
publishDirectory: null,
|
publishDirectory: null,
|
||||||
isStaticSpa: null,
|
isStaticSpa: null,
|
||||||
redirects: [],
|
redirects: [],
|
||||||
@@ -136,7 +125,6 @@ const baseApp: ApplicationNested = {
|
|||||||
username: null,
|
username: null,
|
||||||
dockerContextPath: null,
|
dockerContextPath: null,
|
||||||
rollbackActive: false,
|
rollbackActive: false,
|
||||||
stopGracePeriodSwarm: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("unzipDrop using real zip files", () => {
|
describe("unzipDrop using real zip files", () => {
|
||||||
|
|||||||
335
apps/dokploy/__test__/env/environment.test.ts
vendored
335
apps/dokploy/__test__/env/environment.test.ts
vendored
@@ -1,335 +0,0 @@
|
|||||||
import { prepareEnvironmentVariables } 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",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
|
||||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
|
||||||
|
|
||||||
type MockCreateServiceOptions = {
|
|
||||||
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.StopGracePeriod).toBe(0);
|
|
||||||
expect(typeof settings.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).not.toHaveProperty("StopGracePeriod");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -228,58 +228,5 @@ describe("helpers functions", () => {
|
|||||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
|
"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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const baseApp: ApplicationNested = {
|
|||||||
giteaId: "",
|
giteaId: "",
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
endpointSpecSwarm: null,
|
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
@@ -26,10 +25,8 @@ const baseApp: ApplicationNested = {
|
|||||||
registryUrl: "",
|
registryUrl: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
buildArgs: null,
|
buildArgs: null,
|
||||||
buildSecrets: null,
|
|
||||||
isPreviewDeploymentsActive: false,
|
isPreviewDeploymentsActive: false,
|
||||||
previewBuildArgs: null,
|
previewBuildArgs: null,
|
||||||
previewBuildSecrets: null,
|
|
||||||
triggerType: "push",
|
triggerType: "push",
|
||||||
previewCertificateType: "none",
|
previewCertificateType: "none",
|
||||||
previewEnv: null,
|
previewEnv: null,
|
||||||
@@ -39,22 +36,13 @@ const baseApp: ApplicationNested = {
|
|||||||
previewLimit: 0,
|
previewLimit: 0,
|
||||||
previewCustomCertResolver: null,
|
previewCustomCertResolver: null,
|
||||||
previewWildcard: "",
|
previewWildcard: "",
|
||||||
environmentId: "",
|
project: {
|
||||||
environment: {
|
|
||||||
env: "",
|
env: "",
|
||||||
environmentId: "",
|
organizationId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
|
||||||
description: "",
|
description: "",
|
||||||
|
createdAt: "",
|
||||||
projectId: "",
|
projectId: "",
|
||||||
project: {
|
|
||||||
env: "",
|
|
||||||
organizationId: "",
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
createdAt: "",
|
|
||||||
projectId: "",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
gitlabPathNamespace: "",
|
gitlabPathNamespace: "",
|
||||||
@@ -97,6 +85,7 @@ const baseApp: ApplicationNested = {
|
|||||||
password: null,
|
password: null,
|
||||||
placementSwarm: null,
|
placementSwarm: null,
|
||||||
ports: [],
|
ports: [],
|
||||||
|
projectId: "",
|
||||||
publishDirectory: null,
|
publishDirectory: null,
|
||||||
isStaticSpa: null,
|
isStaticSpa: null,
|
||||||
redirects: [],
|
redirects: [],
|
||||||
@@ -114,7 +103,6 @@ const baseApp: ApplicationNested = {
|
|||||||
updateConfigSwarm: null,
|
updateConfigSwarm: null,
|
||||||
username: null,
|
username: null,
|
||||||
dockerContextPath: null,
|
dockerContextPath: null,
|
||||||
stopGracePeriodSwarm: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseDomain: Domain = {
|
const baseDomain: Domain = {
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -122,22 +121,6 @@ const NetworkSwarmSchema = z.array(
|
|||||||
|
|
||||||
const LabelsSwarmSchema = z.record(z.string());
|
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) => {
|
const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
|
||||||
return z
|
return z
|
||||||
.string()
|
.string()
|
||||||
@@ -193,21 +176,10 @@ const addSwarmSettings = z.object({
|
|||||||
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
|
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
|
||||||
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
|
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
|
||||||
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
|
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
|
||||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
|
||||||
endpointSpecSwarm: createStringToJSONSchema(
|
|
||||||
EndpointSpecSwarmSchema,
|
|
||||||
).nullable(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
|
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 {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
@@ -252,23 +224,12 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
modeSwarm: null,
|
modeSwarm: null,
|
||||||
labelsSwarm: null,
|
labelsSwarm: null,
|
||||||
networkSwarm: null,
|
networkSwarm: null,
|
||||||
stopGracePeriodSwarm: null,
|
|
||||||
endpointSpecSwarm: null,
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addSwarmSettings),
|
resolver: zodResolver(addSwarmSettings),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
const stopGracePeriodValue = hasStopGracePeriodSwarm(data)
|
|
||||||
? data.stopGracePeriodSwarm
|
|
||||||
: null;
|
|
||||||
const normalizedStopGracePeriod =
|
|
||||||
stopGracePeriodValue === null || stopGracePeriodValue === undefined
|
|
||||||
? null
|
|
||||||
: typeof stopGracePeriodValue === "bigint"
|
|
||||||
? stopGracePeriodValue
|
|
||||||
: BigInt(stopGracePeriodValue);
|
|
||||||
form.reset({
|
form.reset({
|
||||||
healthCheckSwarm: data.healthCheckSwarm
|
healthCheckSwarm: data.healthCheckSwarm
|
||||||
? JSON.stringify(data.healthCheckSwarm, null, 2)
|
? JSON.stringify(data.healthCheckSwarm, null, 2)
|
||||||
@@ -294,10 +255,6 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
networkSwarm: data.networkSwarm
|
networkSwarm: data.networkSwarm
|
||||||
? JSON.stringify(data.networkSwarm, null, 2)
|
? JSON.stringify(data.networkSwarm, null, 2)
|
||||||
: null,
|
: null,
|
||||||
stopGracePeriodSwarm: normalizedStopGracePeriod,
|
|
||||||
endpointSpecSwarm: data.endpointSpecSwarm
|
|
||||||
? JSON.stringify(data.endpointSpecSwarm, null, 2)
|
|
||||||
: null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
@@ -318,8 +275,6 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
modeSwarm: data.modeSwarm,
|
modeSwarm: data.modeSwarm,
|
||||||
labelsSwarm: data.labelsSwarm,
|
labelsSwarm: data.labelsSwarm,
|
||||||
networkSwarm: data.networkSwarm,
|
networkSwarm: data.networkSwarm,
|
||||||
stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
|
|
||||||
endpointSpecSwarm: data.endpointSpecSwarm,
|
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Swarm settings updated");
|
toast.success("Swarm settings updated");
|
||||||
@@ -397,9 +352,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
language="json"
|
language="json"
|
||||||
placeholder={`{
|
placeholder={`{
|
||||||
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
|
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
|
||||||
"Interval" : 10000000000,
|
"Interval" : 10000,
|
||||||
"Timeout" : 10000000000,
|
"Timeout" : 10000,
|
||||||
"StartPeriod" : 10000000000,
|
"StartPeriod" : 10000,
|
||||||
"Retries" : 10
|
"Retries" : 10
|
||||||
}`}
|
}`}
|
||||||
className="h-[12rem] font-mono"
|
className="h-[12rem] font-mono"
|
||||||
@@ -452,9 +407,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
language="json"
|
language="json"
|
||||||
placeholder={`{
|
placeholder={`{
|
||||||
"Condition" : "on-failure",
|
"Condition" : "on-failure",
|
||||||
"Delay" : 10000000000,
|
"Delay" : 10000,
|
||||||
"MaxAttempts" : 10,
|
"MaxAttempts" : 10,
|
||||||
"Window" : 10000000000
|
"Window" : 10000
|
||||||
} `}
|
} `}
|
||||||
className="h-[12rem] font-mono"
|
className="h-[12rem] font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
@@ -574,9 +529,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
language="json"
|
language="json"
|
||||||
placeholder={`{
|
placeholder={`{
|
||||||
"Parallelism" : 1,
|
"Parallelism" : 1,
|
||||||
"Delay" : 10000000000,
|
"Delay" : 10000,
|
||||||
"FailureAction" : "continue",
|
"FailureAction" : "continue",
|
||||||
"Monitor" : 10000000000,
|
"Monitor" : 10000,
|
||||||
"MaxFailureRatio" : 10,
|
"MaxFailureRatio" : 10,
|
||||||
"Order" : "start-first"
|
"Order" : "start-first"
|
||||||
}`}
|
}`}
|
||||||
@@ -632,9 +587,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
language="json"
|
language="json"
|
||||||
placeholder={`{
|
placeholder={`{
|
||||||
"Parallelism" : 1,
|
"Parallelism" : 1,
|
||||||
"Delay" : 10000000000,
|
"Delay" : 10000,
|
||||||
"FailureAction" : "continue",
|
"FailureAction" : "continue",
|
||||||
"Monitor" : 10000000000,
|
"Monitor" : 10000,
|
||||||
"MaxFailureRatio" : 10,
|
"MaxFailureRatio" : 10,
|
||||||
"Order" : "start-first"
|
"Order" : "start-first"
|
||||||
}`}
|
}`}
|
||||||
@@ -819,118 +774,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
</FormItem>
|
</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">
|
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -150,10 +150,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<FormLabel>Memory Limit</FormLabel>
|
<FormLabel>Memory Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -185,10 +182,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
name="memoryReservation"
|
name="memoryReservation"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<FormLabel>Memory Reservation</FormLabel>
|
<FormLabel>Memory Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -221,10 +215,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<FormLabel>CPU Limit</FormLabel>
|
<FormLabel>CPU Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -258,10 +249,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<FormLabel>CPU Reservation</FormLabel>
|
<FormLabel>CPU Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import jsyaml from "js-yaml";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { parse, stringify, YAMLParseError } from "yaml";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
@@ -38,11 +38,11 @@ interface Props {
|
|||||||
|
|
||||||
export const validateAndFormatYAML = (yamlText: string) => {
|
export const validateAndFormatYAML = (yamlText: string) => {
|
||||||
try {
|
try {
|
||||||
const obj = parse(yamlText);
|
const obj = jsyaml.load(yamlText);
|
||||||
const formattedYaml = stringify(obj, { indent: 4 });
|
const formattedYaml = jsyaml.dump(obj, { indent: 4 });
|
||||||
return { valid: true, formattedYaml, error: null };
|
return { valid: true, formattedYaml, error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof YAMLParseError) {
|
if (error instanceof jsyaml.YAMLException) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
formattedYaml: yamlText,
|
formattedYaml: yamlText,
|
||||||
@@ -89,7 +89,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
if (!valid) {
|
if (!valid) {
|
||||||
form.setError("traefikConfig", {
|
form.setError("traefikConfig", {
|
||||||
type: "manual",
|
type: "manual",
|
||||||
message: (error as string) || "Invalid YAML",
|
message: error || "Invalid YAML",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,13 +59,7 @@ const mySchema = z.discriminatedUnion("type", [
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("volume"),
|
type: z.literal("volume"),
|
||||||
volumeName: z
|
volumeName: z.string().min(1, "Volume name required"),
|
||||||
.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.",
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.merge(mountSchema),
|
.merge(mountSchema),
|
||||||
z
|
z
|
||||||
@@ -324,7 +318,7 @@ export const AddVolumes = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="max-w-full max-w-[45rem]">
|
<FormItem>
|
||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>Content</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -333,7 +327,7 @@ export const AddVolumes = ({
|
|||||||
placeholder={`NODE_ENV=production
|
placeholder={`NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
`}
|
`}
|
||||||
className="h-96 font-mono "
|
className="h-96 font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -41,13 +41,7 @@ const mySchema = z.discriminatedUnion("type", [
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("volume"),
|
type: z.literal("volume"),
|
||||||
volumeName: z
|
volumeName: z.string().min(1, "Volume name required"),
|
||||||
.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.",
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
.merge(mountSchema),
|
.merge(mountSchema),
|
||||||
z
|
z
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import { Loader2 } from "lucide-react";
|
||||||
import { Check, Copy, Loader2 } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -31,10 +29,9 @@ export const ShowDeployment = ({
|
|||||||
const [data, setData] = useState("");
|
const [data, setData] = useState("");
|
||||||
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
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 [autoScroll, setAutoScroll] = useState(true);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (autoScroll && scrollRef.current) {
|
if (autoScroll && scrollRef.current) {
|
||||||
@@ -109,20 +106,6 @@ export const ShowDeployment = ({
|
|||||||
}
|
}
|
||||||
}, [filteredLogs, autoScroll]);
|
}, [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 || "");
|
const optionalErrors = parseLogs(errorMessage || "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -145,27 +128,13 @@ export const ShowDeployment = ({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Deployment</DialogTitle>
|
<DialogTitle>Deployment</DialogTitle>
|
||||||
<DialogDescription className="flex items-center gap-2">
|
<DialogDescription className="flex items-center gap-2">
|
||||||
<span className="flex items-center gap-2">
|
<span>
|
||||||
See all the details of this deployment |{" "}
|
See all the details of this deployment |{" "}
|
||||||
<Badge variant="blank" className="text-xs">
|
<Badge variant="blank" className="text-xs">
|
||||||
{filteredLogs.length} lines
|
{filteredLogs.length} lines
|
||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</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 && (
|
{serverId && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,15 +1,6 @@
|
|||||||
import {
|
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
|
||||||
ChevronDown,
|
import React, { useEffect, useState } from "react";
|
||||||
ChevronUp,
|
|
||||||
Clock,
|
|
||||||
Loader2,
|
|
||||||
RefreshCcw,
|
|
||||||
RocketIcon,
|
|
||||||
Settings,
|
|
||||||
} from "lucide-react";
|
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
@@ -70,65 +61,12 @@ export const ShowDeployments = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
|
|
||||||
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
||||||
api.rollback.rollback.useMutation();
|
api.rollback.rollback.useMutation();
|
||||||
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
||||||
api.deployment.killProcess.useMutation();
|
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 [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(() => {
|
useEffect(() => {
|
||||||
setUrl(document.location.origin);
|
setUrl(document.location.origin);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -139,7 +77,7 @@ export const ShowDeployments = ({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<CardTitle className="text-xl">Deployments</CardTitle>
|
<CardTitle className="text-xl">Deployments</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
See the last 10 deployments for this {type}
|
See all the 10 last deployments for this {type}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
@@ -156,54 +94,6 @@ export const ShowDeployments = ({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<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 && (
|
{refreshToken && (
|
||||||
<div className="flex flex-col gap-2 text-sm">
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
<span>
|
<span>
|
||||||
@@ -214,9 +104,7 @@ export const ShowDeployments = ({
|
|||||||
<span>Webhook URL: </span>
|
<span>Webhook URL: </span>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<span className="break-all text-muted-foreground">
|
<span className="break-all text-muted-foreground">
|
||||||
{`${url}/api/deploy${
|
{`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`}
|
||||||
type === "compose" ? "/compose" : ""
|
|
||||||
}/${refreshToken}`}
|
|
||||||
</span>
|
</span>
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<RefreshToken id={id} type={type} />
|
<RefreshToken id={id} type={type} />
|
||||||
@@ -242,164 +130,118 @@ export const ShowDeployments = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{deployments?.map((deployment, index) => {
|
{deployments?.map((deployment, index) => (
|
||||||
const titleText = deployment?.title?.trim() || "";
|
<div
|
||||||
const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH;
|
key={deployment.deploymentId}
|
||||||
const isExpanded = expandedDescriptions.has(
|
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||||
deployment.deploymentId,
|
>
|
||||||
);
|
<div className="flex flex-col">
|
||||||
|
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||||
return (
|
{index + 1}. {deployment.status}
|
||||||
<div
|
<StatusTooltip
|
||||||
key={deployment.deploymentId}
|
status={deployment?.status}
|
||||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
className="size-2.5"
|
||||||
>
|
/>
|
||||||
<div className="flex flex-col">
|
</span>
|
||||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{index + 1}. {deployment.status}
|
{deployment.title}
|
||||||
<StatusTooltip
|
</span>
|
||||||
status={deployment?.status}
|
{deployment.description && (
|
||||||
className="size-2.5"
|
<span className="break-all text-sm text-muted-foreground">
|
||||||
/>
|
{deployment.description}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-1">
|
</div>
|
||||||
<span className="break-words text-sm text-muted-foreground whitespace-pre-wrap">
|
<div className="flex flex-col items-end gap-2">
|
||||||
{isExpanded || !needsTruncation
|
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||||
? titleText
|
<DateTooltip date={deployment.createdAt} />
|
||||||
: truncateDescription(titleText)}
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
</span>
|
<Badge
|
||||||
{needsTruncation && (
|
variant="outline"
|
||||||
<button
|
className="text-[10px] gap-1 flex items-center"
|
||||||
type="button"
|
>
|
||||||
onClick={() => {
|
<Clock className="size-3" />
|
||||||
const next = new Set(expandedDescriptions);
|
{formatDuration(
|
||||||
if (next.has(deployment.deploymentId)) {
|
Math.floor(
|
||||||
next.delete(deployment.deploymentId);
|
(new Date(deployment.finishedAt).getTime() -
|
||||||
} else {
|
new Date(deployment.startedAt).getTime()) /
|
||||||
next.add(deployment.deploymentId);
|
1000,
|
||||||
}
|
),
|
||||||
setExpandedDescriptions(next);
|
)}
|
||||||
}}
|
</Badge>
|
||||||
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>
|
||||||
<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">
|
<div className="flex flex-row items-center gap-2">
|
||||||
{deployment.pid && deployment.status === "running" && (
|
{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
|
<DialogAction
|
||||||
title="Kill Process"
|
title="Rollback to this deployment"
|
||||||
description="Are you sure you want to kill the process?"
|
description="Are you sure you want to rollback to this deployment?"
|
||||||
type="default"
|
type="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await killProcess({
|
await rollback({
|
||||||
deploymentId: deployment.deploymentId,
|
rollbackId: deployment.rollback.rollbackId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Process killed successfully");
|
toast.success(
|
||||||
|
"Rollback initiated successfully",
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error killing process");
|
toast.error("Error initiating rollback");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isKillingProcess}
|
isLoading={isRollingBack}
|
||||||
>
|
>
|
||||||
Kill Process
|
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
Rollback
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setActiveLog(deployment);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{deployment?.rollback &&
|
|
||||||
deployment.status === "done" &&
|
|
||||||
type === "application" && (
|
|
||||||
<DialogAction
|
|
||||||
title="Rollback to this deployment"
|
|
||||||
description="Are you sure you want to rollback to this deployment?"
|
|
||||||
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>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ShowDeployment
|
<ShowDeployment
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { api } from "@/utils/api";
|
|||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
env: z.string(),
|
env: z.string(),
|
||||||
buildArgs: z.string(),
|
buildArgs: z.string(),
|
||||||
buildSecrets: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||||
@@ -38,7 +37,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
env: "",
|
env: "",
|
||||||
buildArgs: "",
|
buildArgs: "",
|
||||||
buildSecrets: "",
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addEnvironmentSchema),
|
resolver: zodResolver(addEnvironmentSchema),
|
||||||
});
|
});
|
||||||
@@ -46,18 +44,15 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
// Watch form values
|
// Watch form values
|
||||||
const currentEnv = form.watch("env");
|
const currentEnv = form.watch("env");
|
||||||
const currentBuildArgs = form.watch("buildArgs");
|
const currentBuildArgs = form.watch("buildArgs");
|
||||||
const currentBuildSecrets = form.watch("buildSecrets");
|
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
currentEnv !== (data?.env || "") ||
|
currentEnv !== (data?.env || "") ||
|
||||||
currentBuildArgs !== (data?.buildArgs || "") ||
|
currentBuildArgs !== (data?.buildArgs || "");
|
||||||
currentBuildSecrets !== (data?.buildSecrets || "");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
env: data.env || "",
|
env: data.env || "",
|
||||||
buildArgs: data.buildArgs || "",
|
buildArgs: data.buildArgs || "",
|
||||||
buildSecrets: data.buildSecrets || "",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -66,7 +61,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
mutateAsync({
|
mutateAsync({
|
||||||
env: formData.env,
|
env: formData.env,
|
||||||
buildArgs: formData.buildArgs,
|
buildArgs: formData.buildArgs,
|
||||||
buildSecrets: formData.buildSecrets,
|
|
||||||
applicationId,
|
applicationId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -82,25 +76,9 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
form.reset({
|
form.reset({
|
||||||
env: data?.env || "",
|
env: data?.env || "",
|
||||||
buildArgs: data?.buildArgs || "",
|
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 (
|
return (
|
||||||
<Card className="bg-background px-6 pb-6">
|
<Card className="bg-background px-6 pb-6">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -126,36 +104,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
{data?.buildType === "dockerfile" && (
|
{data?.buildType === "dockerfile" && (
|
||||||
<Secrets
|
<Secrets
|
||||||
name="buildArgs"
|
name="buildArgs"
|
||||||
title="Build-time Arguments"
|
title="Build-time Variables"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
Arguments are available only at build-time. See
|
Available only at build-time. See documentation
|
||||||
documentation
|
|
||||||
<a
|
<a
|
||||||
className="text-primary"
|
className="text-primary"
|
||||||
href="https://docs.docker.com/build/building/variables/"
|
href="https://docs.docker.com/build/guide/build-args/"
|
||||||
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/"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules || false,
|
enableSubmodules: data.enableSubmodules || false,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isLoading } =
|
||||||
api.application.saveGitProvider.useMutation();
|
api.application.saveGitProdiver.useMutation();
|
||||||
|
|
||||||
const form = useForm<GitProvider>({
|
const form = useForm<GitProvider>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
toast.success("Application deployed successfully");
|
toast.success("Application deployed successfully");
|
||||||
refetch();
|
refetch();
|
||||||
router.push(
|
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(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -182,16 +182,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
id={deployment.previewDeploymentId}
|
id={deployment.previewDeploymentId}
|
||||||
type="previewDeployment"
|
type="previewDeployment"
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
>
|
/>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<RocketIcon className="size-4" />
|
|
||||||
Deployments
|
|
||||||
</Button>
|
|
||||||
</ShowDeploymentsModal>
|
|
||||||
|
|
||||||
<AddPreviewDomain
|
<AddPreviewDomain
|
||||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ const schema = z
|
|||||||
.object({
|
.object({
|
||||||
env: z.string(),
|
env: z.string(),
|
||||||
buildArgs: z.string(),
|
buildArgs: z.string(),
|
||||||
buildSecrets: z.string(),
|
|
||||||
wildcardDomain: z.string(),
|
wildcardDomain: z.string(),
|
||||||
port: z.number(),
|
port: z.number(),
|
||||||
previewLimit: z.number(),
|
previewLimit: z.number(),
|
||||||
@@ -110,7 +109,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
form.reset({
|
form.reset({
|
||||||
env: data.previewEnv || "",
|
env: data.previewEnv || "",
|
||||||
buildArgs: data.previewBuildArgs || "",
|
buildArgs: data.previewBuildArgs || "",
|
||||||
buildSecrets: data.previewBuildSecrets || "",
|
|
||||||
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
||||||
port: data.previewPort || 3000,
|
port: data.previewPort || 3000,
|
||||||
previewLabels: data.previewLabels || [],
|
previewLabels: data.previewLabels || [],
|
||||||
@@ -129,7 +127,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
updateApplication({
|
updateApplication({
|
||||||
previewEnv: formData.env,
|
previewEnv: formData.env,
|
||||||
previewBuildArgs: formData.buildArgs,
|
previewBuildArgs: formData.buildArgs,
|
||||||
previewBuildSecrets: formData.buildSecrets,
|
|
||||||
previewWildcard: formData.wildcardDomain,
|
previewWildcard: formData.wildcardDomain,
|
||||||
previewPort: formData.port,
|
previewPort: formData.port,
|
||||||
previewLabels: formData.previewLabels,
|
previewLabels: formData.previewLabels,
|
||||||
@@ -470,37 +467,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
{data?.buildType === "dockerfile" && (
|
{data?.buildType === "dockerfile" && (
|
||||||
<Secrets
|
<Secrets
|
||||||
name="buildArgs"
|
name="buildArgs"
|
||||||
title="Build-time Arguments"
|
title="Build-time Variables"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
Arguments are available only at build-time. See
|
Available only at build-time. See documentation
|
||||||
documentation
|
|
||||||
<a
|
<a
|
||||||
className="text-primary"
|
className="text-primary"
|
||||||
href="https://docs.docker.com/build/building/variables/"
|
href="https://docs.docker.com/build/guide/build-args/"
|
||||||
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/"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "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 { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
@@ -57,7 +57,6 @@ export const commonCronExpressions = [
|
|||||||
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
|
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
|
||||||
{ label: "Every 15 minutes", value: "*/15 * * * *" },
|
{ label: "Every 15 minutes", value: "*/15 * * * *" },
|
||||||
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
|
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
|
||||||
{ label: "Custom", value: "custom" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
@@ -116,91 +115,10 @@ interface Props {
|
|||||||
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
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) => {
|
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@@ -459,9 +377,63 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScheduleFormField
|
<FormField
|
||||||
|
control={form.control}
|
||||||
name="cronExpression"
|
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>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(scheduleTypeForm === "application" ||
|
{(scheduleTypeForm === "application" ||
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -34,9 +33,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||||
const [runningSchedules, setRunningSchedules] = useState<Set<string>>(
|
|
||||||
new Set(),
|
|
||||||
);
|
|
||||||
const {
|
const {
|
||||||
data: schedules,
|
data: schedules,
|
||||||
isLoading: isLoadingSchedules,
|
isLoading: isLoadingSchedules,
|
||||||
@@ -50,32 +46,19 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
||||||
api.schedule.delete.useMutation();
|
api.schedule.delete.useMutation();
|
||||||
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
|
|
||||||
|
|
||||||
const handleRunManually = async (scheduleId: string) => {
|
const { mutateAsync: runManually, isLoading } =
|
||||||
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
|
api.schedule.runManually.useMutation();
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||||
<CardHeader className="px-0">
|
<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">
|
<div className="flex flex-col gap-2">
|
||||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
Scheduled Tasks
|
Scheduled Tasks
|
||||||
@@ -84,6 +67,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
Schedule tasks to run automatically at specified intervals.
|
Schedule tasks to run automatically at specified intervals.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{schedules && schedules.length > 0 && (
|
{schedules && schedules.length > 0 && (
|
||||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||||
)}
|
)}
|
||||||
@@ -91,7 +75,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
{isLoadingSchedules ? (
|
{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" />
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
<span className="text-sm text-muted-foreground/70">
|
<span className="text-sm text-muted-foreground/70">
|
||||||
Loading scheduled tasks...
|
Loading scheduled tasks...
|
||||||
@@ -107,15 +91,15 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={schedule.scheduleId}
|
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 items-start gap-3">
|
||||||
<div className="flex flex-shrink-0 h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||||
<Clock className="size-4 text-primary/70" />
|
<Clock className="size-4 text-primary/70" />
|
||||||
</div>
|
</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 flex-wrap">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-sm font-medium leading-none [overflow-wrap:anywhere] line-clamp-3">
|
<h3 className="text-sm font-medium leading-none">
|
||||||
{schedule.name}
|
{schedule.name}
|
||||||
</h3>
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -125,7 +109,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
{schedule.enabled ? "Enabled" : "Disabled"}
|
{schedule.enabled ? "Enabled" : "Disabled"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="font-mono text-[10px] bg-transparent"
|
className="font-mono text-[10px] bg-transparent"
|
||||||
@@ -148,25 +132,27 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{schedule.command && (
|
{schedule.command && (
|
||||||
<div className="flex items-start gap-2 max-w-full">
|
<div className="flex items-center gap-2">
|
||||||
<Terminal className="size-3.5 text-muted-foreground/70 flex-shrink-0 mt-0.5" />
|
<Terminal className="size-3.5 text-muted-foreground/70" />
|
||||||
<code className="font-mono text-[10px] text-muted-foreground/70 break-all max-w-[calc(100%-20px)]">
|
<code className="font-mono text-[10px] text-muted-foreground/70">
|
||||||
{schedule.command}
|
{schedule.command}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5 md:gap-1.5">
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<ShowDeploymentsModal
|
<ShowDeploymentsModal
|
||||||
id={schedule.scheduleId}
|
id={schedule.scheduleId}
|
||||||
type="schedule"
|
type="schedule"
|
||||||
serverId={serverId || undefined}
|
serverId={serverId || undefined}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<ClipboardList className="size-4 transition-colors" />
|
<ClipboardList className="size-4 transition-colors " />
|
||||||
</Button>
|
</Button>
|
||||||
</ShowDeploymentsModal>
|
</ShowDeploymentsModal>
|
||||||
|
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -174,26 +160,37 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={runningSchedules.has(schedule.scheduleId)}
|
isLoading={isLoading}
|
||||||
onClick={() =>
|
onClick={async () => {
|
||||||
handleRunManually(schedule.scheduleId)
|
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) ? (
|
<Play className="size-4 transition-colors" />
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play className="size-4 transition-colors" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Run Manual Schedule</TooltipContent>
|
<TooltipContent>Run Manual Schedule</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<HandleSchedules
|
<HandleSchedules
|
||||||
scheduleId={schedule.scheduleId}
|
scheduleId={schedule.scheduleId}
|
||||||
id={id}
|
id={id}
|
||||||
scheduleType={scheduleType}
|
scheduleType={scheduleType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete Schedule"
|
title="Delete Schedule"
|
||||||
description="Are you sure you want to delete this schedule?"
|
description="Are you sure you want to delete this schedule?"
|
||||||
@@ -217,8 +214,8 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-red-500/10"
|
className="group hover:bg-red-500/10 "
|
||||||
disabled={isDeleting}
|
isLoading={isDeleting}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -229,7 +226,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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" />
|
<Clock className="size-8 mb-4 text-muted-foreground" />
|
||||||
<p className="text-lg font-medium text-muted-foreground">
|
<p className="text-lg font-medium text-muted-foreground">
|
||||||
No scheduled tasks
|
No scheduled tasks
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
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 { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -41,19 +47,13 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import type { CacheType } from "../domains/handle-domain";
|
import type { CacheType } from "../domains/handle-domain";
|
||||||
import { ScheduleFormField } from "../schedules/handle-schedules";
|
import { commonCronExpressions } from "../schedules/handle-schedules";
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||||
volumeName: z
|
volumeName: z.string().min(1, "Volume name is required"),
|
||||||
.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.",
|
|
||||||
),
|
|
||||||
prefix: z.string(),
|
prefix: z.string(),
|
||||||
keepLatestCount: z.coerce
|
keepLatestCount: z.coerce
|
||||||
.number()
|
.number()
|
||||||
@@ -306,9 +306,64 @@ export const HandleVolumeBackups = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ScheduleFormField
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
name="cronExpression"
|
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
|
<FormField
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -39,7 +38,6 @@ export const ShowVolumeBackups = ({
|
|||||||
type = "application",
|
type = "application",
|
||||||
serverId,
|
serverId,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [runningBackups, setRunningBackups] = useState<Set<string>>(new Set());
|
|
||||||
const {
|
const {
|
||||||
data: volumeBackups,
|
data: volumeBackups,
|
||||||
isLoading: isLoadingVolumeBackups,
|
isLoading: isLoadingVolumeBackups,
|
||||||
@@ -53,46 +51,34 @@ export const ShowVolumeBackups = ({
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
|
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
|
||||||
api.volumeBackups.delete.useMutation();
|
api.volumeBackups.delete.useMutation();
|
||||||
const { mutateAsync: runManually } =
|
|
||||||
api.volumeBackups.runManually.useMutation();
|
|
||||||
|
|
||||||
const handleRunManually = async (volumeBackupId: string) => {
|
const { mutateAsync: runManually, isLoading } =
|
||||||
setRunningBackups((prev) => new Set(prev).add(volumeBackupId));
|
api.volumeBackups.runManually.useMutation();
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||||
<CardHeader className="px-0">
|
<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">
|
<div className="flex flex-col gap-2">
|
||||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
Volume Backups
|
Volume Backups
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Schedule volume backups to run automatically at specified
|
Schedule volume backups to run automatically at specified
|
||||||
intervals
|
intervals.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{volumeBackups && volumeBackups.length > 0 && (
|
{volumeBackups && volumeBackups.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RestoreVolumeBackups
|
<RestoreVolumeBackups
|
||||||
id={id}
|
id={id}
|
||||||
@@ -107,7 +93,7 @@ export const ShowVolumeBackups = ({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
{isLoadingVolumeBackups ? (
|
{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" />
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
<span className="text-sm text-muted-foreground/70">
|
<span className="text-sm text-muted-foreground/70">
|
||||||
Loading volume backups...
|
Loading volume backups...
|
||||||
@@ -127,13 +113,13 @@ export const ShowVolumeBackups = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={volumeBackup.volumeBackupId}
|
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">
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||||
<DatabaseBackup className="size-4 text-primary/70" />
|
<DatabaseBackup className="size-4 text-primary/70" />
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-sm font-medium leading-none">
|
<h3 className="text-sm font-medium leading-none">
|
||||||
{volumeBackup.name}
|
{volumeBackup.name}
|
||||||
@@ -157,16 +143,18 @@ export const ShowVolumeBackups = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<ShowDeploymentsModal
|
||||||
id={volumeBackup.volumeBackupId}
|
id={volumeBackup.volumeBackupId}
|
||||||
type="volumeBackup"
|
type="volumeBackup"
|
||||||
serverId={serverId || undefined}
|
serverId={serverId || undefined}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<ClipboardList className="size-4 transition-colors" />
|
<ClipboardList className="size-4 transition-colors " />
|
||||||
</Button>
|
</Button>
|
||||||
</ShowDeploymentsModal>
|
</ShowDeploymentsModal>
|
||||||
|
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -174,18 +162,25 @@ export const ShowVolumeBackups = ({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={runningBackups.has(
|
isLoading={isLoading}
|
||||||
volumeBackup.volumeBackupId,
|
onClick={async () => {
|
||||||
)}
|
toast.success("Volume backup run successfully");
|
||||||
onClick={() =>
|
|
||||||
handleRunManually(volumeBackup.volumeBackupId)
|
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) ? (
|
<Play className="size-4 transition-colors" />
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play className="size-4 transition-colors" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -193,11 +188,13 @@ export const ShowVolumeBackups = ({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<HandleVolumeBackups
|
<HandleVolumeBackups
|
||||||
volumeBackupId={volumeBackup.volumeBackupId}
|
volumeBackupId={volumeBackup.volumeBackupId}
|
||||||
id={id}
|
id={id}
|
||||||
volumeBackupType={type}
|
volumeBackupType={type}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete Volume Backup"
|
title="Delete Volume Backup"
|
||||||
description="Are you sure you want to delete this volume backup?"
|
description="Are you sure you want to delete this volume backup?"
|
||||||
@@ -221,7 +218,7 @@ export const ShowVolumeBackups = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-red-500/10"
|
className="group hover:bg-red-500/10 "
|
||||||
isLoading={isDeleting}
|
isLoading={isDeleting}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
@@ -233,7 +230,7 @@ export const ShowVolumeBackups = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</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" />
|
<DatabaseBackup className="size-8 mb-4 text-muted-foreground" />
|
||||||
<p className="text-lg font-medium text-muted-foreground">
|
<p className="text-lg font-medium text-muted-foreground">
|
||||||
No volume backups
|
No volume backups
|
||||||
|
|||||||
@@ -101,10 +101,8 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
deleteVolumes,
|
deleteVolumes,
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
push(
|
push(`/dashboard/project/${result?.projectId}`);
|
||||||
`/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`,
|
toast.success("deleted successfully");
|
||||||
);
|
|
||||||
toast.success("Service deleted successfully");
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
toast.success("Compose deployed successfully");
|
toast.success("Compose deployed successfully");
|
||||||
refetch();
|
refetch();
|
||||||
router.push(
|
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(() => {
|
.catch(() => {
|
||||||
@@ -195,7 +195,6 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
appType={data?.composeType || "docker-compose"}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -35,7 +35,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<AddComposeFile>({
|
const form = useForm<AddComposeFile>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -54,12 +53,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data?.composeFile !== undefined) {
|
|
||||||
setHasUnsavedChanges(composeFile !== data.composeFile);
|
|
||||||
}
|
|
||||||
}, [composeFile, data?.composeFile]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: AddComposeFile) => {
|
const onSubmit = async (data: AddComposeFile) => {
|
||||||
const { valid, error } = validateAndFormatYAML(data.composeFile);
|
const { valid, error } = validateAndFormatYAML(data.composeFile);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
@@ -74,12 +67,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
composeId,
|
composeId,
|
||||||
composeFile: data.composeFile,
|
composeFile: data.composeFile,
|
||||||
composePath: "./docker-compose.yml",
|
|
||||||
sourceType: "raw",
|
sourceType: "raw",
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Compose config Updated");
|
toast.success("Compose config Updated");
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
refetch();
|
refetch();
|
||||||
await utils.compose.getConvertedCompose.invalidate({
|
await utils.compose.getConvertedCompose.invalidate({
|
||||||
composeId,
|
composeId,
|
||||||
@@ -108,19 +99,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex flex-col gap-4 ">
|
<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 {...form}>
|
||||||
<form
|
<form
|
||||||
id="hook-form-save-compose-file"
|
id="hook-form-save-compose-file"
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
triggerType: data.triggerType,
|
triggerType: data.triggerType,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ interface Props {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
badgeStateColor;
|
||||||
|
|
||||||
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||||
const [option, setOption] = useState<"swarm" | "native">("native");
|
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||||
const [containerId, setContainerId] = useState<string | undefined>();
|
const [containerId, setContainerId] = useState<string | undefined>();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
DatabaseZap,
|
DatabaseZap,
|
||||||
|
Info,
|
||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -61,7 +62,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ScheduleFormField } from "../../application/schedules/handle-schedules";
|
import { commonCronExpressions } from "../../application/schedules/handle-schedules";
|
||||||
|
|
||||||
type CacheType = "cache" | "fetch";
|
type CacheType = "cache" | "fetch";
|
||||||
|
|
||||||
@@ -578,9 +579,66 @@ export const HandleBackup = ({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
<ScheduleFormField name="schedule" formControl={form.control} />
|
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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="prefix"
|
name="prefix"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import _ from "lodash";
|
import { debounce } from "lodash";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
@@ -236,7 +236,7 @@ export const RestoreBackup = ({
|
|||||||
const currentDatabaseType = form.watch("databaseType");
|
const currentDatabaseType = form.watch("databaseType");
|
||||||
const metadata = form.watch("metadata");
|
const metadata = form.watch("metadata");
|
||||||
|
|
||||||
const debouncedSetSearch = _.debounce((value: string) => {
|
const debouncedSetSearch = debounce((value: string) => {
|
||||||
setDebouncedSearchTerm(value);
|
setDebouncedSearchTerm(value);
|
||||||
}, 350);
|
}, 350);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import { Download as DownloadIcon, Loader2, Pause, Play } from "lucide-react";
|
||||||
import {
|
|
||||||
Check,
|
|
||||||
Copy,
|
|
||||||
Download as DownloadIcon,
|
|
||||||
Loader2,
|
|
||||||
Pause,
|
|
||||||
Play,
|
|
||||||
} from "lucide-react";
|
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -75,7 +67,6 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
const isPausedRef = useRef(false);
|
const isPausedRef = useRef(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
const [copied, setCopied] = React.useState(false);
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (autoScroll && scrollRef.current) {
|
if (autoScroll && scrollRef.current) {
|
||||||
@@ -246,29 +237,6 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
URL.revokeObjectURL(url);
|
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[]) => {
|
const handleFilter = (logs: LogLine[]) => {
|
||||||
return logs.filter((log) => {
|
return logs.filter((log) => {
|
||||||
const logType = getLogType(log.message).type;
|
const logType = getLogType(log.message).type;
|
||||||
@@ -352,21 +320,6 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
{isPaused ? "Resume" : "Pause"}
|
{isPaused ? "Resume" : "Pause"}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FancyAnsi } from "fancy-ansi";
|
import { FancyAnsi } from "fancy-ansi";
|
||||||
import _ from "lodash";
|
import { escapeRegExp } from "lodash";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -47,7 +47,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const htmlContent = fancyAnsi.toHtml(text);
|
const htmlContent = fancyAnsi.toHtml(text);
|
||||||
const searchRegex = new RegExp(`(${_.escapeRegExp(term)})`, "gi");
|
const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
|
||||||
|
|
||||||
const modifiedContent = htmlContent.replace(
|
const modifiedContent = htmlContent.replace(
|
||||||
searchRegex,
|
searchRegex,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
containerId?: string;
|
containerId: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const addonFit = new FitAddon();
|
const addonFit = new FitAddon();
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2 mt-4">
|
<div className="flex flex-col gap-2">
|
||||||
<span>
|
<span>
|
||||||
Select way to connect to <b>{containerId}</b>
|
Select way to connect to <b>{containerId}</b>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -281,7 +281,6 @@ export const ImpersonationBar = () => {
|
|||||||
<div className="flex items-center gap-4 flex-1 flex-wrap">
|
<div className="flex items-center gap-4 flex-1 flex-wrap">
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
className="object-cover"
|
|
||||||
src={data?.user?.image || ""}
|
src={data?.user?.image || ""}
|
||||||
alt={data?.user?.name || ""}
|
alt={data?.user?.name || ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
In order to make the database reachable through the internet, you
|
In order to make the database reachable trought internet is
|
||||||
must set a port and ensure that the port is not being used by
|
required to set a port, make sure the port is not used by another
|
||||||
another application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-col gap-4">
|
<CardContent className="flex w-full flex-col gap-4">
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
In order to make the database reachable through the internet, you
|
In order to make the database reachable trought internet is
|
||||||
must set a port and ensure that the port is not being used by
|
required to set a port, make sure the port is not used by another
|
||||||
another application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-col gap-4">
|
<CardContent className="flex w-full flex-col gap-4">
|
||||||
|
|||||||
@@ -102,9 +102,9 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
In order to make the database reachable through the internet, you
|
In order to make the database reachable trought internet is
|
||||||
must set a port and ensure that the port is not being used by
|
required to set a port, make sure the port is not used by another
|
||||||
another application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-col gap-4">
|
<CardContent className="flex w-full flex-col gap-4">
|
||||||
|
|||||||
@@ -104,9 +104,9 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
In order to make the database reachable through the internet, you
|
In order to make the database reachable trought internet is
|
||||||
must set a port and ensure that the port is not being used by
|
required to set a port, make sure the port is not used by another
|
||||||
another application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-col gap-4">
|
<CardContent className="flex w-full flex-col gap-4">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { TemplateGenerator } from "@/components/dashboard/project/ai/template-generator";
|
import { TemplateGenerator } from "@/components/dashboard/project/ai/template-generator";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
environmentId: string;
|
projectId: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddAiAssistant = ({ environmentId }: Props) => {
|
export const AddAiAssistant = ({ projectId }: Props) => {
|
||||||
return <TemplateGenerator environmentId={environmentId} />;
|
return <TemplateGenerator projectId={projectId} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -64,11 +64,11 @@ const AddTemplateSchema = z.object({
|
|||||||
type AddTemplate = z.infer<typeof AddTemplateSchema>;
|
type AddTemplate = z.infer<typeof AddTemplateSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
environmentId: string;
|
projectId: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddApplication = ({ environmentId, projectName }: Props) => {
|
export const AddApplication = ({ projectId, projectName }: Props) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
@@ -76,10 +76,6 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
|
|
||||||
const hasServers = servers && servers.length > 0;
|
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 } =
|
const { mutateAsync, isLoading, error, isError } =
|
||||||
api.application.create.useMutation();
|
api.application.create.useMutation();
|
||||||
@@ -98,15 +94,15 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
appName: data.appName,
|
appName: data.appName,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
projectId,
|
||||||
environmentId,
|
serverId: data.serverId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Created");
|
toast.success("Service Created");
|
||||||
form.reset();
|
form.reset();
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
await utils.environment.one.invalidate({
|
await utils.project.one.invalidate({
|
||||||
environmentId,
|
projectId,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -161,7 +157,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{shouldShowServerDropdown && (
|
{hasServers && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="serverId"
|
name="serverId"
|
||||||
@@ -190,27 +186,13 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
|
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={
|
defaultValue={field.value}
|
||||||
field.value || (!isCloud ? "dokploy" : undefined)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue placeholder="Select a Server" />
|
||||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<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) => (
|
{servers?.map((server) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={server.serverId}
|
key={server.serverId}
|
||||||
@@ -224,9 +206,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>
|
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
|
||||||
</SelectLabel>
|
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -65,11 +65,11 @@ const AddComposeSchema = z.object({
|
|||||||
type AddCompose = z.infer<typeof AddComposeSchema>;
|
type AddCompose = z.infer<typeof AddComposeSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
environmentId: string;
|
projectId: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddCompose = ({ environmentId, projectName }: Props) => {
|
export const AddCompose = ({ projectId, projectName }: Props) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const slug = slugify(projectName);
|
const slug = slugify(projectName);
|
||||||
@@ -78,14 +78,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
const { mutateAsync, isLoading, error, isError } =
|
const { mutateAsync, isLoading, error, isError } =
|
||||||
api.compose.create.useMutation();
|
api.compose.create.useMutation();
|
||||||
|
|
||||||
// Get environment data to extract projectId
|
|
||||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
|
||||||
|
|
||||||
const hasServers = servers && servers.length > 0;
|
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>({
|
const form = useForm<AddCompose>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -105,17 +98,16 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
environmentId,
|
projectId,
|
||||||
composeType: data.composeType,
|
composeType: data.composeType,
|
||||||
appName: data.appName,
|
appName: data.appName,
|
||||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
serverId: data.serverId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Compose Created");
|
toast.success("Compose Created");
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
// Invalidate the project query to refresh the environment data
|
await utils.project.one.invalidate({
|
||||||
await utils.environment.one.invalidate({
|
projectId,
|
||||||
environmentId,
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -173,7 +165,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{shouldShowServerDropdown && (
|
{hasServers && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="serverId"
|
name="serverId"
|
||||||
@@ -202,27 +194,13 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
|
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={
|
defaultValue={field.value}
|
||||||
field.value || (!isCloud ? "dokploy" : undefined)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue placeholder="Select a Server" />
|
||||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<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) => (
|
{servers?.map((server) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={server.serverId}
|
key={server.serverId}
|
||||||
@@ -236,9 +214,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>
|
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
|
||||||
</SelectLabel>
|
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import { api } from "@/utils/api";
|
|||||||
type DbType = typeof mySchema._type.type;
|
type DbType = typeof mySchema._type.type;
|
||||||
|
|
||||||
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||||
mongo: "mongo:7",
|
mongo: "mongo:6",
|
||||||
mariadb: "mariadb:11",
|
mariadb: "mariadb:11",
|
||||||
mysql: "mysql:8",
|
mysql: "mysql:8",
|
||||||
postgres: "postgres:15",
|
postgres: "postgres:15",
|
||||||
@@ -83,12 +83,7 @@ const baseDatabaseSchema = z.object({
|
|||||||
message:
|
message:
|
||||||
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
|
||||||
}),
|
}),
|
||||||
databasePassword: z
|
databasePassword: z.string(),
|
||||||
.string()
|
|
||||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
|
||||||
message:
|
|
||||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
|
||||||
}),
|
|
||||||
dockerImage: z.string(),
|
dockerImage: z.string(),
|
||||||
description: z.string().nullable(),
|
description: z.string().nullable(),
|
||||||
serverId: z.string().nullable(),
|
serverId: z.string().nullable(),
|
||||||
@@ -117,13 +112,7 @@ const mySchema = z.discriminatedUnion("type", [
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("mysql"),
|
type: z.literal("mysql"),
|
||||||
databaseRootPassword: z
|
databaseRootPassword: z.string().default(""),
|
||||||
.string()
|
|
||||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
|
||||||
message:
|
|
||||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
databaseUser: z.string().default("mysql"),
|
databaseUser: z.string().default("mysql"),
|
||||||
databaseName: z.string().default("mysql"),
|
databaseName: z.string().default("mysql"),
|
||||||
})
|
})
|
||||||
@@ -132,13 +121,7 @@ const mySchema = z.discriminatedUnion("type", [
|
|||||||
.object({
|
.object({
|
||||||
type: z.literal("mariadb"),
|
type: z.literal("mariadb"),
|
||||||
dockerImage: z.string().default("mariadb:4"),
|
dockerImage: z.string().default("mariadb:4"),
|
||||||
databaseRootPassword: z
|
databaseRootPassword: z.string().default(""),
|
||||||
.string()
|
|
||||||
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
|
||||||
message:
|
|
||||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
databaseUser: z.string().default("mariadb"),
|
databaseUser: z.string().default("mariadb"),
|
||||||
databaseName: z.string().default("mariadb"),
|
databaseName: z.string().default("mariadb"),
|
||||||
})
|
})
|
||||||
@@ -171,15 +154,14 @@ const databasesMap = {
|
|||||||
type AddDatabase = z.infer<typeof mySchema>;
|
type AddDatabase = z.infer<typeof mySchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
environmentId: string;
|
projectId: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
export const AddDatabase = ({ projectId, projectName }: Props) => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const slug = slugify(projectName);
|
const slug = slugify(projectName);
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
const postgresMutation = api.postgres.create.useMutation();
|
const postgresMutation = api.postgres.create.useMutation();
|
||||||
const mongoMutation = api.mongo.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 mariadbMutation = api.mariadb.create.useMutation();
|
||||||
const mysqlMutation = api.mysql.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;
|
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>({
|
const form = useForm<AddDatabase>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -228,8 +203,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
appName: data.appName,
|
appName: data.appName,
|
||||||
dockerImage: defaultDockerImage,
|
dockerImage: defaultDockerImage,
|
||||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
projectId,
|
||||||
environmentId,
|
serverId: data.serverId,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -241,7 +216,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
|
|
||||||
databaseUser:
|
databaseUser:
|
||||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
serverId: data.serverId,
|
||||||
});
|
});
|
||||||
} else if (data.type === "mongo") {
|
} else if (data.type === "mongo") {
|
||||||
promise = mongoMutation.mutateAsync({
|
promise = mongoMutation.mutateAsync({
|
||||||
@@ -249,24 +224,25 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
databasePassword: data.databasePassword,
|
databasePassword: data.databasePassword,
|
||||||
databaseUser:
|
databaseUser:
|
||||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
serverId: data.serverId,
|
||||||
replicaSets: data.replicaSets,
|
replicaSets: data.replicaSets,
|
||||||
});
|
});
|
||||||
} else if (data.type === "redis") {
|
} else if (data.type === "redis") {
|
||||||
promise = redisMutation.mutateAsync({
|
promise = redisMutation.mutateAsync({
|
||||||
...commonParams,
|
...commonParams,
|
||||||
databasePassword: data.databasePassword,
|
databasePassword: data.databasePassword,
|
||||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
serverId: data.serverId,
|
||||||
|
projectId,
|
||||||
});
|
});
|
||||||
} else if (data.type === "mariadb") {
|
} else if (data.type === "mariadb") {
|
||||||
promise = mariadbMutation.mutateAsync({
|
promise = mariadbMutation.mutateAsync({
|
||||||
...commonParams,
|
...commonParams,
|
||||||
databasePassword: data.databasePassword,
|
databasePassword: data.databasePassword,
|
||||||
databaseRootPassword: data.databaseRootPassword || "",
|
databaseRootPassword: data.databaseRootPassword,
|
||||||
databaseName: data.databaseName || "mariadb",
|
databaseName: data.databaseName || "mariadb",
|
||||||
databaseUser:
|
databaseUser:
|
||||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||||
serverId: data.serverId === "dokploy" ? null : data.serverId,
|
serverId: data.serverId,
|
||||||
});
|
});
|
||||||
} else if (data.type === "mysql") {
|
} else if (data.type === "mysql") {
|
||||||
promise = mysqlMutation.mutateAsync({
|
promise = mysqlMutation.mutateAsync({
|
||||||
@@ -275,8 +251,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
databaseName: data.databaseName || "mysql",
|
databaseName: data.databaseName || "mysql",
|
||||||
databaseUser:
|
databaseUser:
|
||||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
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: "",
|
databaseUser: "",
|
||||||
});
|
});
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
// Invalidate the project query to refresh the environment data
|
await utils.project.one.invalidate({
|
||||||
await utils.environment.one.invalidate({
|
projectId,
|
||||||
environmentId,
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -407,7 +382,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{shouldShowServerDropdown && (
|
{hasServers && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="serverId"
|
name="serverId"
|
||||||
@@ -416,29 +391,13 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
<FormLabel>Select a Server</FormLabel>
|
<FormLabel>Select a Server</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={
|
defaultValue={field.value || ""}
|
||||||
field.value || (!isCloud ? "dokploy" : undefined)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue placeholder="Select a Server" />
|
||||||
placeholder={
|
|
||||||
!isCloud ? "Dokploy" : "Select a Server"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<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) => (
|
{servers?.map((server) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={server.serverId}
|
key={server.serverId}
|
||||||
@@ -448,7 +407,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>
|
<SelectLabel>
|
||||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
Servers ({servers?.length})
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -73,11 +73,11 @@ import { api } from "@/utils/api";
|
|||||||
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
|
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
environmentId: string;
|
projectId: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
||||||
@@ -91,9 +91,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get environment data to extract projectId
|
|
||||||
const { data: environment } = api.environment.one.useQuery({ environmentId });
|
|
||||||
|
|
||||||
// Save to localStorage when customBaseUrl changes
|
// Save to localStorage when customBaseUrl changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (customBaseUrl) {
|
if (customBaseUrl) {
|
||||||
@@ -141,10 +138,6 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
const hasServers = servers && servers.length > 0;
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -171,7 +164,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Search Template"
|
placeholder="Search Template"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="w-full"
|
className="w-full sm:w-[200px]"
|
||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -248,7 +241,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setViewMode(viewMode === "detailed" ? "icon" : "detailed")
|
setViewMode(viewMode === "detailed" ? "icon" : "detailed")
|
||||||
}
|
}
|
||||||
className="h-9 w-9 flex-shrink-0"
|
className="h-9 w-9"
|
||||||
>
|
>
|
||||||
{viewMode === "detailed" ? (
|
{viewMode === "detailed" ? (
|
||||||
<LayoutGrid className="size-4" />
|
<LayoutGrid className="size-4" />
|
||||||
@@ -434,7 +427,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
project.
|
project.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|
||||||
{shouldShowServerDropdown && (
|
{hasServers && (
|
||||||
<div>
|
<div>
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -463,29 +456,12 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
onValueChange={(e) => {
|
onValueChange={(e) => {
|
||||||
setServerId(e);
|
setServerId(e);
|
||||||
}}
|
}}
|
||||||
defaultValue={
|
|
||||||
!isCloud ? "dokploy" : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue placeholder="Select a Server" />
|
||||||
placeholder={
|
|
||||||
!isCloud ? "Dokploy" : "Select a Server"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<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) => (
|
{servers?.map((server) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={server.serverId}
|
key={server.serverId}
|
||||||
@@ -500,8 +476,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>
|
<SelectLabel>
|
||||||
Servers (
|
Servers ({servers?.length})
|
||||||
{servers?.length + (!isCloud ? 1 : 0)})
|
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -515,20 +490,16 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const promise = mutateAsync({
|
const promise = mutateAsync({
|
||||||
serverId:
|
projectId,
|
||||||
serverId === "dokploy"
|
serverId: serverId || undefined,
|
||||||
? undefined
|
|
||||||
: serverId,
|
|
||||||
environmentId,
|
|
||||||
id: template.id,
|
id: template.id,
|
||||||
baseUrl: customBaseUrl,
|
baseUrl: customBaseUrl,
|
||||||
});
|
});
|
||||||
toast.promise(promise, {
|
toast.promise(promise, {
|
||||||
loading: "Setting up...",
|
loading: "Setting up...",
|
||||||
success: () => {
|
success: () => {
|
||||||
// Invalidate the project query to refresh the environment data
|
utils.project.one.invalidate({
|
||||||
utils.environment.one.invalidate({
|
projectId,
|
||||||
environmentId,
|
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
return `${template.name} template created successfully`;
|
return `${template.name} template created successfully`;
|
||||||
|
|||||||
@@ -1,457 +0,0 @@
|
|||||||
import type { findEnvironmentsByProjectId } from "@dokploy/server";
|
|
||||||
import {
|
|
||||||
ChevronDownIcon,
|
|
||||||
PencilIcon,
|
|
||||||
PlusIcon,
|
|
||||||
Terminal,
|
|
||||||
TrashIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Action buttons for non-production environments */}
|
|
||||||
{/* <EnvironmentVariables environmentId={environment.environmentId}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Terminal className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</EnvironmentVariables> */}
|
|
||||||
{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) => {
|
export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
|
||||||
// Get servers from the API
|
// Get servers from the API
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
const hasServers = servers && servers.length > 0;
|
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) => {
|
const handleExampleClick = (example: string) => {
|
||||||
setTemplateInfo({ ...templateInfo, userInput: example });
|
setTemplateInfo({ ...templateInfo, userInput: example });
|
||||||
@@ -53,58 +48,34 @@ export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shouldShowServerDropdown && (
|
{hasServers && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="server-deploy">
|
<Label htmlFor="server-deploy">
|
||||||
Select the server where you want to deploy (optional)
|
Select the server where you want to deploy (optional)
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={templateInfo.server?.serverId}
|
||||||
templateInfo.server?.serverId ||
|
|
||||||
(!isCloud ? "dokploy" : undefined)
|
|
||||||
}
|
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
if (value === "dokploy") {
|
const server = servers?.find((s) => s.serverId === value);
|
||||||
|
if (server) {
|
||||||
setTemplateInfo({
|
setTemplateInfo({
|
||||||
...templateInfo,
|
...templateInfo,
|
||||||
server: undefined,
|
server: server,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
const server = servers?.find((s) => s.serverId === value);
|
|
||||||
if (server) {
|
|
||||||
setTemplateInfo({
|
|
||||||
...templateInfo,
|
|
||||||
server: server,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue
|
<SelectValue placeholder="Select a server" />
|
||||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<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) => (
|
{servers?.map((server) => (
|
||||||
<SelectItem key={server.serverId} value={server.serverId}>
|
<SelectItem key={server.serverId} value={server.serverId}>
|
||||||
{server.name}
|
{server.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>
|
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
|
||||||
</SelectLabel>
|
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const StepThree = ({ templateInfo }: StepProps) => {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold">Configuration Files</h3>
|
<h3 className="text-sm font-semibold">Configuration Files</h3>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
{templateInfo?.details?.configFiles?.map((file, index) => (
|
{templateInfo?.details?.configFiles.map((file, index) => (
|
||||||
<li key={index}>
|
<li key={index}>
|
||||||
<strong className="text-sm font-semibold">
|
<strong className="text-sm font-semibold">
|
||||||
{file.filePath}
|
{file.filePath}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Bot, PlusCircle, Trash2 } from "lucide-react";
|
import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
@@ -27,6 +27,7 @@ export interface StepProps {
|
|||||||
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||||
const suggestions = templateInfo.suggestions || [];
|
const suggestions = templateInfo.suggestions || [];
|
||||||
const selectedVariant = templateInfo.details;
|
const selectedVariant = templateInfo.details;
|
||||||
|
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } =
|
const { mutateAsync, isLoading, error, isError } =
|
||||||
api.ai.suggest.useMutation();
|
api.ai.suggest.useMutation();
|
||||||
@@ -43,7 +44,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
setTemplateInfo({
|
setTemplateInfo({
|
||||||
...templateInfo,
|
...templateInfo,
|
||||||
suggestions: data || [],
|
suggestions: data,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -53,6 +54,10 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
|||||||
});
|
});
|
||||||
}, [templateInfo.userInput]);
|
}, [templateInfo.userInput]);
|
||||||
|
|
||||||
|
const toggleShowValue = (name: string) => {
|
||||||
|
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||||
|
};
|
||||||
|
|
||||||
const handleEnvVariableChange = (
|
const handleEnvVariableChange = (
|
||||||
index: number,
|
index: number,
|
||||||
field: "name" | "value",
|
field: "name" | "value",
|
||||||
@@ -303,9 +308,11 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
|||||||
placeholder="Variable Name"
|
placeholder="Variable Name"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<div className="relative">
|
<div className="flex-1 relative">
|
||||||
<Input
|
<Input
|
||||||
type={"password"}
|
type={
|
||||||
|
showValues[env.name] ? "text" : "password"
|
||||||
|
}
|
||||||
value={env.value}
|
value={env.value}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleEnvVariableChange(
|
handleEnvVariableChange(
|
||||||
@@ -316,6 +323,19 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
|||||||
}
|
}
|
||||||
placeholder="Variable Value"
|
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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -417,14 +437,13 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
|||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<ScrollArea className="w-full rounded-md border">
|
<ScrollArea className="w-full rounded-md border">
|
||||||
<div className="p-4 space-y-4">
|
<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">
|
<div className="text-sm text-muted-foreground mb-4">
|
||||||
This template requires the following
|
This template requires the following
|
||||||
configuration files to be mounted:
|
configuration files to be mounted:
|
||||||
</div>
|
</div>
|
||||||
{selectedVariant?.configFiles?.map(
|
{selectedVariant.configFiles.map(
|
||||||
(config, index) => (
|
(config, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ interface Details {
|
|||||||
envVariables: EnvVariable[];
|
envVariables: EnvVariable[];
|
||||||
shortDescription: string;
|
shortDescription: string;
|
||||||
domains: Domain[];
|
domains: Domain[];
|
||||||
configFiles?: Mount[];
|
configFiles: Mount[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Mount {
|
interface Mount {
|
||||||
@@ -90,11 +90,11 @@ export const { useStepper, steps, Scoped } = defineStepper(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
environmentId: string;
|
projectId: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplateGenerator = ({ environmentId }: Props) => {
|
export const TemplateGenerator = ({ projectId }: Props) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const stepper = useStepper();
|
const stepper = useStepper();
|
||||||
const { data: aiSettings } = api.ai.getAll.useQuery();
|
const { data: aiSettings } = api.ai.getAll.useQuery();
|
||||||
@@ -121,7 +121,7 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
|
|||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
environmentId: environmentId,
|
projectId,
|
||||||
id: templateInfo.details?.id || "",
|
id: templateInfo.details?.id || "",
|
||||||
name: templateInfo?.details?.name || "",
|
name: templateInfo?.details?.name || "",
|
||||||
description: templateInfo?.details?.shortDescription || "",
|
description: templateInfo?.details?.shortDescription || "",
|
||||||
@@ -138,9 +138,8 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Compose Created");
|
toast.success("Compose Created");
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
// Invalidate the project query to refresh the environment data
|
await utils.project.one.invalidate({
|
||||||
await utils.environment.one.invalidate({
|
projectId,
|
||||||
environmentId,
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -15,13 +15,6 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
export type Services = {
|
export type Services = {
|
||||||
@@ -43,35 +36,23 @@ export type Services = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface DuplicateProjectProps {
|
interface DuplicateProjectProps {
|
||||||
environmentId: string;
|
projectId: string;
|
||||||
services: Services[];
|
services: Services[];
|
||||||
selectedServiceIds: string[];
|
selectedServiceIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DuplicateProject = ({
|
export const DuplicateProject = ({
|
||||||
environmentId,
|
projectId,
|
||||||
services,
|
services,
|
||||||
selectedServiceIds,
|
selectedServiceIds,
|
||||||
}: DuplicateProjectProps) => {
|
}: DuplicateProjectProps) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "existing-environment"
|
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project"
|
||||||
const [selectedTargetProject, setSelectedTargetProject] =
|
|
||||||
useState<string>("");
|
|
||||||
const [selectedTargetEnvironment, setSelectedTargetEnvironment] =
|
|
||||||
useState<string>("");
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const router = useRouter();
|
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) =>
|
const selectedServices = services.filter((service) =>
|
||||||
selectedServiceIds.includes(service.id),
|
selectedServiceIds.includes(service.id),
|
||||||
);
|
);
|
||||||
@@ -80,29 +61,6 @@ export const DuplicateProject = ({
|
|||||||
api.project.duplicate.useMutation({
|
api.project.duplicate.useMutation({
|
||||||
onSuccess: async (newProject) => {
|
onSuccess: async (newProject) => {
|
||||||
await utils.project.all.invalidate();
|
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(
|
toast.success(
|
||||||
duplicateType === "new-project"
|
duplicateType === "new-project"
|
||||||
? "Project duplicated successfully"
|
? "Project duplicated successfully"
|
||||||
@@ -110,9 +68,7 @@ export const DuplicateProject = ({
|
|||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
if (duplicateType === "new-project") {
|
if (duplicateType === "new-project") {
|
||||||
router.push(
|
router.push(`/dashboard/project/${newProject.projectId}`);
|
||||||
`/dashboard/project/${newProject?.projectId}/environment/${newProject?.environmentId}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -126,20 +82,8 @@ export const DuplicateProject = ({
|
|||||||
return;
|
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({
|
await duplicateProject({
|
||||||
sourceEnvironmentId: selectedTargetEnvironment,
|
sourceProjectId: projectId,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
includeServices: true,
|
includeServices: true,
|
||||||
@@ -147,7 +91,7 @@ export const DuplicateProject = ({
|
|||||||
id: service.id,
|
id: service.id,
|
||||||
type: service.type,
|
type: service.type,
|
||||||
})),
|
})),
|
||||||
duplicateInSameProject: duplicateType === "existing-environment",
|
duplicateInSameProject: duplicateType === "same-project",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,8 +105,6 @@ export const DuplicateProject = ({
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setDuplicateType("new-project");
|
setDuplicateType("new-project");
|
||||||
setSelectedTargetProject("");
|
|
||||||
setSelectedTargetEnvironment("");
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -185,14 +127,7 @@ export const DuplicateProject = ({
|
|||||||
<Label>Duplicate to</Label>
|
<Label>Duplicate to</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={duplicateType}
|
value={duplicateType}
|
||||||
onValueChange={(value) => {
|
onValueChange={setDuplicateType}
|
||||||
setDuplicateType(value);
|
|
||||||
// Reset selections when changing type
|
|
||||||
if (value !== "existing-environment") {
|
|
||||||
setSelectedTargetProject("");
|
|
||||||
setSelectedTargetEnvironment("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="grid gap-2"
|
className="grid gap-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -200,13 +135,8 @@ export const DuplicateProject = ({
|
|||||||
<Label htmlFor="new-project">New project</Label>
|
<Label htmlFor="new-project">New project</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem
|
<RadioGroupItem value="same-project" id="same-project" />
|
||||||
value="existing-environment"
|
<Label htmlFor="same-project">Same project</Label>
|
||||||
id="existing-environment"
|
|
||||||
/>
|
|
||||||
<Label htmlFor="existing-environment">
|
|
||||||
Existing environment
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</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">
|
<div className="grid gap-2">
|
||||||
<Label>Selected services to duplicate</Label>
|
<Label>Selected services to duplicate</Label>
|
||||||
<div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
|
<div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
|
||||||
@@ -325,26 +187,18 @@ export const DuplicateProject = ({
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleDuplicate} disabled={isLoading}>
|
||||||
onClick={handleDuplicate}
|
|
||||||
disabled={
|
|
||||||
isLoading ||
|
|
||||||
(duplicateType === "new-project" && !name) ||
|
|
||||||
(duplicateType === "existing-environment" &&
|
|
||||||
(!selectedTargetProject || !selectedTargetEnvironment))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{duplicateType === "new-project"
|
{duplicateType === "new-project"
|
||||||
? "Duplicating to new project..."
|
? "Duplicating project..."
|
||||||
: "Duplicating to environment..."}
|
: "Duplicating services..."}
|
||||||
</>
|
</>
|
||||||
) : duplicateType === "new-project" ? (
|
) : duplicateType === "new-project" ? (
|
||||||
"Duplicate to new project"
|
"Duplicate project"
|
||||||
) : (
|
) : (
|
||||||
"Duplicate to environment"
|
"Duplicate services"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { Terminal } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const updateEnvironmentSchema = z.object({
|
|
||||||
env: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type UpdateEnvironment = z.infer<typeof updateEnvironmentSchema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
environmentId: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync, error, isError, isLoading } =
|
|
||||||
api.environment.update.useMutation();
|
|
||||||
const { data } = api.environment.one.useQuery(
|
|
||||||
{
|
|
||||||
environmentId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!environmentId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm<UpdateEnvironment>({
|
|
||||||
defaultValues: {
|
|
||||||
env: data?.env ?? "",
|
|
||||||
},
|
|
||||||
resolver: zodResolver(updateEnvironmentSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
form.reset({
|
|
||||||
env: data.env ?? "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [data, form, form.reset]);
|
|
||||||
|
|
||||||
const onSubmit = async (formData: UpdateEnvironment) => {
|
|
||||||
await mutateAsync({
|
|
||||||
env: formData.env || "",
|
|
||||||
environmentId: environmentId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Environment variables updated successfully");
|
|
||||||
utils.environment.one.invalidate({ environmentId });
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error updating the environment variables");
|
|
||||||
})
|
|
||||||
.finally(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
|
|
||||||
e.preventDefault();
|
|
||||||
form.handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [form, onSubmit, isLoading, isOpen]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="w-full cursor-pointer space-x-3"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<Terminal className="size-4" />
|
|
||||||
<span>Environment Variables</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-6xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Environment Variables</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Update the environment variables that are accessible to all services
|
|
||||||
in this environment.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
|
||||||
<AlertBlock type="info">
|
|
||||||
Use this syntax to reference environment-level variables in your
|
|
||||||
service environments:{" "}
|
|
||||||
<code>API_URL=${"{{environment.API_URL}}"}</code>
|
|
||||||
</AlertBlock>
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="grid items-center gap-4">
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="grid w-full gap-4 "
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="env"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Environment variables</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<CodeEditor
|
|
||||||
lineWrapping
|
|
||||||
language="properties"
|
|
||||||
wrapperClassName="h-[35rem] font-mono"
|
|
||||||
placeholder={`NODE_ENV=development
|
|
||||||
DATABASE_URL=postgresql://localhost:5432/mydb
|
|
||||||
API_KEY=your-api-key-here
|
|
||||||
|
|
||||||
`}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<pre>
|
|
||||||
<FormMessage />
|
|
||||||
</pre>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button isLoading={isLoading} type="submit">
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -101,18 +101,7 @@ export const HandleProject = ({ projectId }: Props) => {
|
|||||||
toast.success(projectId ? "Project Updated" : "Project Created");
|
toast.success(projectId ? "Project Updated" : "Project Created");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
const projectIdToUse =
|
router.push(`/dashboard/project/${data?.projectId}`);
|
||||||
data && "project" in data ? data.project.projectId : undefined;
|
|
||||||
const environmentIdToUse =
|
|
||||||
data && "environment" in data
|
|
||||||
? data.environment.environmentId
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (environmentIdToUse && projectIdToUse) {
|
|
||||||
router.push(
|
|
||||||
`/dashboard/project/${projectIdToUse}/environment/${environmentIdToUse}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
refetch();
|
refetch();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,21 +81,6 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
|||||||
.finally(() => {});
|
.finally(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
|
|
||||||
e.preventDefault();
|
|
||||||
form.handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [form, onSubmit, isLoading, isOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -45,6 +44,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -52,14 +52,12 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { TimeBadge } from "@/components/ui/time-badge";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { HandleProject } from "./handle-project";
|
import { HandleProject } from "./handle-project";
|
||||||
import { ProjectEnvironment } from "./project-environment";
|
import { ProjectEnvironment } from "./project-environment";
|
||||||
|
|
||||||
export const ShowProjects = () => {
|
export const ShowProjects = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
const { data, isLoading } = api.project.all.useQuery();
|
const { data, isLoading } = api.project.all.useQuery();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { mutateAsync } = api.project.remove.useMutation();
|
const { mutateAsync } = api.project.remove.useMutation();
|
||||||
@@ -98,30 +96,22 @@ export const ShowProjects = () => {
|
|||||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||||
break;
|
break;
|
||||||
case "services": {
|
case "services": {
|
||||||
const aTotalServices = a.environments.reduce((total, env) => {
|
const aTotalServices =
|
||||||
return (
|
a.mariadb.length +
|
||||||
total +
|
a.mongo.length +
|
||||||
(env.applications?.length || 0) +
|
a.mysql.length +
|
||||||
(env.mariadb?.length || 0) +
|
a.postgres.length +
|
||||||
(env.mongo?.length || 0) +
|
a.redis.length +
|
||||||
(env.mysql?.length || 0) +
|
a.applications.length +
|
||||||
(env.postgres?.length || 0) +
|
a.compose.length;
|
||||||
(env.redis?.length || 0) +
|
const bTotalServices =
|
||||||
(env.compose?.length || 0)
|
b.mariadb.length +
|
||||||
);
|
b.mongo.length +
|
||||||
}, 0);
|
b.mysql.length +
|
||||||
const bTotalServices = b.environments.reduce((total, env) => {
|
b.postgres.length +
|
||||||
return (
|
b.redis.length +
|
||||||
total +
|
b.applications.length +
|
||||||
(env.applications?.length || 0) +
|
b.compose.length;
|
||||||
(env.mariadb?.length || 0) +
|
|
||||||
(env.mongo?.length || 0) +
|
|
||||||
(env.mysql?.length || 0) +
|
|
||||||
(env.postgres?.length || 0) +
|
|
||||||
(env.redis?.length || 0) +
|
|
||||||
(env.compose?.length || 0)
|
|
||||||
);
|
|
||||||
}, 0);
|
|
||||||
comparison = aTotalServices - bTotalServices;
|
comparison = aTotalServices - bTotalServices;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -137,11 +127,6 @@ export const ShowProjects = () => {
|
|||||||
<BreadcrumbSidebar
|
<BreadcrumbSidebar
|
||||||
list={[{ name: "Projects", href: "/dashboard/projects" }]}
|
list={[{ name: "Projects", href: "/dashboard/projects" }]}
|
||||||
/>
|
/>
|
||||||
{!isCloud && (
|
|
||||||
<div className="absolute top-5 right-5">
|
|
||||||
<TimeBadge />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
@@ -155,6 +140,7 @@ export const ShowProjects = () => {
|
|||||||
Create and manage your projects
|
Create and manage your projects
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{(auth?.role === "owner" || auth?.canCreateProjects) && (
|
{(auth?.role === "owner" || auth?.canCreateProjects) && (
|
||||||
<div className="">
|
<div className="">
|
||||||
<HandleProject />
|
<HandleProject />
|
||||||
@@ -172,13 +158,12 @@ export const ShowProjects = () => {
|
|||||||
<>
|
<>
|
||||||
<div className="flex max-sm:flex-col gap-4 items-center w-full">
|
<div className="flex max-sm:flex-col gap-4 items-center w-full">
|
||||||
<div className="flex-1 relative max-sm:w-full">
|
<div className="flex-1 relative max-sm:w-full">
|
||||||
<FocusShortcutInput
|
<Input
|
||||||
placeholder="Filter projects..."
|
placeholder="Filter projects..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pr-10"
|
className="pr-10"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
|
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
|
||||||
@@ -216,40 +201,23 @@ export const ShowProjects = () => {
|
|||||||
)}
|
)}
|
||||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
|
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
|
||||||
{filteredProjects?.map((project) => {
|
{filteredProjects?.map((project) => {
|
||||||
const emptyServices = project?.environments
|
const emptyServices =
|
||||||
.map(
|
project?.mariadb.length === 0 &&
|
||||||
(env) =>
|
project?.mongo.length === 0 &&
|
||||||
env.applications.length === 0 &&
|
project?.mysql.length === 0 &&
|
||||||
env.mariadb.length === 0 &&
|
project?.postgres.length === 0 &&
|
||||||
env.mongo.length === 0 &&
|
project?.redis.length === 0 &&
|
||||||
env.mysql.length === 0 &&
|
project?.applications.length === 0 &&
|
||||||
env.postgres.length === 0 &&
|
project?.compose.length === 0;
|
||||||
env.redis.length === 0 &&
|
|
||||||
env.applications.length === 0 &&
|
|
||||||
env.compose.length === 0,
|
|
||||||
)
|
|
||||||
.every(Boolean);
|
|
||||||
|
|
||||||
const totalServices = project?.environments
|
const totalServices =
|
||||||
.map(
|
project?.mariadb.length +
|
||||||
(env) =>
|
project?.mongo.length +
|
||||||
env.mariadb.length +
|
project?.mysql.length +
|
||||||
env.mongo.length +
|
project?.postgres.length +
|
||||||
env.mysql.length +
|
project?.redis.length +
|
||||||
env.postgres.length +
|
project?.applications.length +
|
||||||
env.redis.length +
|
project?.compose.length;
|
||||||
env.applications.length +
|
|
||||||
env.compose.length,
|
|
||||||
)
|
|
||||||
.reduce((acc, curr) => acc + curr, 0);
|
|
||||||
|
|
||||||
const haveServicesWithDomains = project?.environments
|
|
||||||
.map(
|
|
||||||
(env) =>
|
|
||||||
env.applications.length > 0 ||
|
|
||||||
env.compose.length > 0,
|
|
||||||
)
|
|
||||||
.some(Boolean);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -257,10 +225,11 @@ export const ShowProjects = () => {
|
|||||||
className="w-full lg:max-w-md"
|
className="w-full lg:max-w-md"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`}
|
href={`/dashboard/project/${project.projectId}`}
|
||||||
>
|
>
|
||||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
||||||
{haveServicesWithDomains ? (
|
{project.applications.length > 0 ||
|
||||||
|
project.compose.length > 0 ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -275,102 +244,80 @@ export const ShowProjects = () => {
|
|||||||
className="w-[200px] space-y-2 overflow-y-auto max-h-[400px]"
|
className="w-[200px] space-y-2 overflow-y-auto max-h-[400px]"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{project.environments.some(
|
{project.applications.length > 0 && (
|
||||||
(env) => env.applications.length > 0,
|
|
||||||
) && (
|
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
Applications
|
Applications
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{project.environments.map((env) =>
|
{project.applications.map((app) => (
|
||||||
env.applications.map((app) => (
|
<div key={app.applicationId}>
|
||||||
<div key={app.applicationId}>
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
|
||||||
|
{app.name}
|
||||||
|
<StatusTooltip
|
||||||
|
status={app.applicationStatus}
|
||||||
|
/>
|
||||||
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
{app.domains.map((domain) => (
|
||||||
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
|
<DropdownMenuItem
|
||||||
{app.name}
|
key={domain.domainId}
|
||||||
<StatusTooltip
|
asChild
|
||||||
status={
|
>
|
||||||
app.applicationStatus
|
<Link
|
||||||
}
|
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||||
/>
|
target="_blank"
|
||||||
</DropdownMenuLabel>
|
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{app.domains.map((domain) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={domain.domainId}
|
|
||||||
asChild
|
|
||||||
>
|
>
|
||||||
<Link
|
<span className="truncate">
|
||||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
{domain.host}
|
||||||
target="_blank"
|
</span>
|
||||||
href={`${
|
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||||
domain.https
|
</Link>
|
||||||
? "https"
|
</DropdownMenuItem>
|
||||||
: "http"
|
))}
|
||||||
}://${domain.host}${
|
</DropdownMenuGroup>
|
||||||
domain.path
|
</div>
|
||||||
}`}
|
))}
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{domain.host}
|
|
||||||
</span>
|
|
||||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</div>
|
|
||||||
)),
|
|
||||||
)}
|
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
)}
|
)}
|
||||||
{project.environments.some(
|
{project.compose.length > 0 && (
|
||||||
(env) => env.compose.length > 0,
|
|
||||||
) && (
|
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
Compose
|
Compose
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{project.environments.map((env) =>
|
{project.compose.map((comp) => (
|
||||||
env.compose.map((comp) => (
|
<div key={comp.composeId}>
|
||||||
<div key={comp.composeId}>
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
|
||||||
|
{comp.name}
|
||||||
|
<StatusTooltip
|
||||||
|
status={comp.composeStatus}
|
||||||
|
/>
|
||||||
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
{comp.domains.map((domain) => (
|
||||||
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
|
<DropdownMenuItem
|
||||||
{comp.name}
|
key={domain.domainId}
|
||||||
<StatusTooltip
|
asChild
|
||||||
status={comp.composeStatus}
|
>
|
||||||
/>
|
<Link
|
||||||
</DropdownMenuLabel>
|
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||||
<DropdownMenuSeparator />
|
target="_blank"
|
||||||
{comp.domains.map((domain) => (
|
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||||
<DropdownMenuItem
|
|
||||||
key={domain.domainId}
|
|
||||||
asChild
|
|
||||||
>
|
>
|
||||||
<Link
|
<span className="truncate">
|
||||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
{domain.host}
|
||||||
target="_blank"
|
</span>
|
||||||
href={`${
|
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||||
domain.https
|
</Link>
|
||||||
? "https"
|
</DropdownMenuItem>
|
||||||
: "http"
|
))}
|
||||||
}://${domain.host}${
|
</DropdownMenuGroup>
|
||||||
domain.path
|
</div>
|
||||||
}`}
|
))}
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
{domain.host}
|
|
||||||
</span>
|
|
||||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</div>
|
|
||||||
)),
|
|
||||||
)}
|
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
In order to make the database reachable through the internet, you
|
In order to make the database reachable trought internet is
|
||||||
must set a port and ensure that the port is not being used by
|
required to set a port, make sure the port is not used by another
|
||||||
another application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-col gap-4">
|
<CardContent className="flex w-full flex-col gap-4">
|
||||||
|
|||||||
@@ -3,10 +3,6 @@
|
|||||||
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
|
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
|
||||||
extractServices,
|
|
||||||
type Services,
|
|
||||||
} from "@/components/dashboard/settings/users/add-permissions";
|
|
||||||
import {
|
import {
|
||||||
MariadbIcon,
|
MariadbIcon,
|
||||||
MongodbIcon,
|
MongodbIcon,
|
||||||
@@ -24,34 +20,13 @@ import {
|
|||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import {
|
||||||
|
extractServices,
|
||||||
|
type Services,
|
||||||
|
} from "@/pages/dashboard/project/[projectId]";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { StatusTooltip } from "../shared/status-tooltip";
|
import { StatusTooltip } from "../shared/status-tooltip";
|
||||||
|
|
||||||
// Extended Services type to include environmentId and environmentName for search navigation
|
|
||||||
type SearchServices = Services & {
|
|
||||||
environmentId: string;
|
|
||||||
environmentName: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractAllServicesFromProject = (project: any): SearchServices[] => {
|
|
||||||
const allServices: SearchServices[] = [];
|
|
||||||
|
|
||||||
// Iterate through all environments in the project
|
|
||||||
project.environments?.forEach((environment: any) => {
|
|
||||||
const environmentServices = extractServices(environment);
|
|
||||||
const servicesWithEnvironmentId: SearchServices[] = environmentServices.map(
|
|
||||||
(service) => ({
|
|
||||||
...service,
|
|
||||||
environmentId: environment.environmentId,
|
|
||||||
environmentName: environment.name,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
allServices.push(...servicesWithEnvironmentId);
|
|
||||||
});
|
|
||||||
|
|
||||||
return allServices;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SearchCommand = () => {
|
export const SearchCommand = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
@@ -88,42 +63,31 @@ export const SearchCommand = () => {
|
|||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup heading={"Projects"}>
|
<CommandGroup heading={"Projects"}>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{data?.map((project) => {
|
{data?.map((project) => (
|
||||||
const productionEnvironment = project.environments.find(
|
<CommandItem
|
||||||
(environment) => environment.name === "production",
|
key={project.projectId}
|
||||||
);
|
onSelect={() => {
|
||||||
|
router.push(`/dashboard/project/${project.projectId}`);
|
||||||
if (!productionEnvironment) return null;
|
setOpen(false);
|
||||||
|
}}
|
||||||
return (
|
>
|
||||||
<CommandItem
|
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
||||||
key={project.projectId}
|
{project.name}
|
||||||
onSelect={() => {
|
</CommandItem>
|
||||||
router.push(
|
))}
|
||||||
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
|
|
||||||
);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
|
||||||
{project.name} / {productionEnvironment!.name}
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
<CommandSeparator />
|
<CommandSeparator />
|
||||||
<CommandGroup heading={"Services"}>
|
<CommandGroup heading={"Services"}>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{data?.map((project) => {
|
{data?.map((project) => {
|
||||||
const applications: SearchServices[] =
|
const applications: Services[] = extractServices(project);
|
||||||
extractAllServicesFromProject(project);
|
|
||||||
return applications.map((application) => (
|
return applications.map((application) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={application.id}
|
key={application.id}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/project/${project.projectId}/environment/${application.environmentId}/services/${application.type}/${application.id}`,
|
`/dashboard/project/${project.projectId}/services/${application.type}/${application.id}`,
|
||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
@@ -150,8 +114,7 @@ export const SearchCommand = () => {
|
|||||||
<CircuitBoard className="h-6 w-6 mr-2" />
|
<CircuitBoard className="h-6 w-6 mr-2" />
|
||||||
)}
|
)}
|
||||||
<span className="flex-grow">
|
<span className="flex-grow">
|
||||||
{project.name} / {application.environmentName} /{" "}
|
{project.name} / {application.name}{" "}
|
||||||
{application.name}{" "}
|
|
||||||
<div style={{ display: "none" }}>{application.id}</div>
|
<div style={{ display: "none" }}>{application.id}</div>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -65,11 +65,6 @@ export const AddCertificate = () => {
|
|||||||
const { mutateAsync, isError, error, isLoading } =
|
const { mutateAsync, isError, error, isLoading } =
|
||||||
api.certificates.create.useMutation();
|
api.certificates.create.useMutation();
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
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 form = useForm<AddCertificate>({
|
const form = useForm<AddCertificate>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -90,7 +85,7 @@ export const AddCertificate = () => {
|
|||||||
certificateData: data.certificateData,
|
certificateData: data.certificateData,
|
||||||
privateKey: data.privateKey,
|
privateKey: data.privateKey,
|
||||||
autoRenew: data.autoRenew,
|
autoRenew: data.autoRenew,
|
||||||
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
|
serverId: data.serverId,
|
||||||
organizationId: "",
|
organizationId: "",
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -179,70 +174,52 @@ export const AddCertificate = () => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{shouldShowServerDropdown && (
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="serverId"
|
||||||
name="serverId"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem>
|
<TooltipProvider delayDuration={0}>
|
||||||
<TooltipProvider delayDuration={0}>
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
|
Select a Server {!isCloud && "(Optional)"}
|
||||||
Select a Server {!isCloud && "(Optional)"}
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
<HelpCircle className="size-4 text-muted-foreground" />
|
</FormLabel>
|
||||||
</FormLabel>
|
</TooltipTrigger>
|
||||||
</TooltipTrigger>
|
</Tooltip>
|
||||||
</Tooltip>
|
</TooltipProvider>
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={
|
defaultValue={field.value}
|
||||||
field.value || (!isCloud ? "dokploy" : undefined)
|
>
|
||||||
}
|
<SelectTrigger>
|
||||||
>
|
<SelectValue placeholder="Select a Server" />
|
||||||
<SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectValue
|
<SelectContent>
|
||||||
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
|
<SelectGroup>
|
||||||
/>
|
{servers?.map((server) => (
|
||||||
</SelectTrigger>
|
<SelectItem
|
||||||
<SelectContent>
|
key={server.serverId}
|
||||||
<SelectGroup>
|
value={server.serverId}
|
||||||
{!isCloud && (
|
>
|
||||||
<SelectItem value="dokploy">
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
<span className="flex items-center gap-2 justify-between w-full">
|
<span>{server.name}</span>
|
||||||
<span>Dokploy</span>
|
<span className="text-muted-foreground text-xs self-center">
|
||||||
<span className="text-muted-foreground text-xs self-center">
|
{server.ipAddress}
|
||||||
Default
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</span>
|
||||||
)}
|
</SelectItem>
|
||||||
{servers?.map((server) => (
|
))}
|
||||||
<SelectItem
|
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||||
key={server.serverId}
|
</SelectGroup>
|
||||||
value={server.serverId}
|
</SelectContent>
|
||||||
>
|
</Select>
|
||||||
<span className="flex items-center gap-2 justify-between w-full">
|
<FormMessage />
|
||||||
<span>{server.name}</span>
|
</FormItem>
|
||||||
<span className="text-muted-foreground text-xs self-center">
|
)}
|
||||||
{server.ipAddress}
|
/>
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectLabel>
|
|
||||||
Servers ({servers?.length + (!isCloud ? 1 : 0)})
|
|
||||||
</SelectLabel>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<DialogFooter className="flex w-full flex-row !justify-end">
|
<DialogFooter className="flex w-full flex-row !justify-end">
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{(isError || isErrorConnection) && (
|
{(isError || isErrorConnection) && (
|
||||||
<AlertBlock type="error" className="w-full">
|
<AlertBlock type="error" className="break-words">
|
||||||
{connectionError?.message || error?.message}
|
{connectionError?.message || error?.message}
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -26,12 +27,18 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useUrl } from "@/utils/hooks/use-url";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
name: z.string().min(1, { message: "Name is required" }),
|
name: z.string().min(1, {
|
||||||
username: z.string().min(1, { message: "Username is required" }),
|
message: "Name is required",
|
||||||
email: z.string().email().optional(),
|
}),
|
||||||
apiToken: z.string().min(1, { message: "API Token is required" }),
|
username: z.string().min(1, {
|
||||||
|
message: "Username is required",
|
||||||
|
}),
|
||||||
|
password: z.string().min(1, {
|
||||||
|
message: "App Password is required",
|
||||||
|
}),
|
||||||
workspaceName: z.string().optional(),
|
workspaceName: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,12 +47,14 @@ type Schema = z.infer<typeof Schema>;
|
|||||||
export const AddBitbucketProvider = () => {
|
export const AddBitbucketProvider = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const _url = useUrl();
|
||||||
const { mutateAsync, error, isError } = api.bitbucket.create.useMutation();
|
const { mutateAsync, error, isError } = api.bitbucket.create.useMutation();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
const _router = useRouter();
|
||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
username: "",
|
||||||
apiToken: "",
|
password: "",
|
||||||
workspaceName: "",
|
workspaceName: "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
@@ -54,8 +63,7 @@ export const AddBitbucketProvider = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
form.reset({
|
||||||
username: "",
|
username: "",
|
||||||
email: "",
|
password: "",
|
||||||
apiToken: "",
|
|
||||||
workspaceName: "",
|
workspaceName: "",
|
||||||
});
|
});
|
||||||
}, [form, isOpen]);
|
}, [form, isOpen]);
|
||||||
@@ -63,11 +71,10 @@ export const AddBitbucketProvider = () => {
|
|||||||
const onSubmit = async (data: Schema) => {
|
const onSubmit = async (data: Schema) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
bitbucketUsername: data.username,
|
bitbucketUsername: data.username,
|
||||||
apiToken: data.apiToken,
|
appPassword: data.password,
|
||||||
bitbucketWorkspaceName: data.workspaceName || "",
|
bitbucketWorkspaceName: data.workspaceName || "",
|
||||||
authId: auth?.id || "",
|
authId: auth?.id || "",
|
||||||
name: data.name || "",
|
name: data.name || "",
|
||||||
bitbucketEmail: data.email || "",
|
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await utils.gitProvider.getAll.invalidate();
|
await utils.gitProvider.getAll.invalidate();
|
||||||
@@ -106,46 +113,37 @@ export const AddBitbucketProvider = () => {
|
|||||||
>
|
>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<AlertBlock type="warning">
|
|
||||||
Bitbucket App Passwords are deprecated for new providers. Use
|
|
||||||
an API Token instead. Existing providers with App Passwords
|
|
||||||
will continue to work until 9th June 2026.
|
|
||||||
</AlertBlock>
|
|
||||||
|
|
||||||
<div className="mt-1 text-sm">
|
|
||||||
Manage tokens in
|
|
||||||
<Link
|
|
||||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
|
||||||
target="_blank"
|
|
||||||
className="inline-flex items-center gap-1 ml-1"
|
|
||||||
>
|
|
||||||
<span>Bitbucket settings</span>
|
|
||||||
<ExternalLink className="w-fit text-primary size-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<ul className="list-disc list-inside ml-4 text-sm text-muted-foreground">
|
|
||||||
<li className="text-muted-foreground text-sm">
|
|
||||||
Click on Create API token with scopes
|
|
||||||
</li>
|
|
||||||
<li className="text-muted-foreground text-sm">
|
|
||||||
Select the expiration date (Max 1 year)
|
|
||||||
</li>
|
|
||||||
<li className="text-muted-foreground text-sm">
|
|
||||||
Select Bitbucket product.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Select the following scopes:
|
To integrate your Bitbucket account, you need to create a new
|
||||||
|
App Password in your Bitbucket settings. Follow these steps:
|
||||||
</p>
|
</p>
|
||||||
|
<ol className="list-decimal list-inside text-sm text-muted-foreground">
|
||||||
<ul className="list-disc list-inside ml-4 text-sm text-muted-foreground">
|
<li className="flex flex-row gap-2 items-center">
|
||||||
<li>read:repository:bitbucket</li>
|
Create new App Password{" "}
|
||||||
<li>read:pullrequest:bitbucket</li>
|
<Link
|
||||||
<li>read:webhook:bitbucket</li>
|
href="https://bitbucket.org/account/settings/app-passwords/new"
|
||||||
<li>read:workspace:bitbucket</li>
|
target="_blank"
|
||||||
<li>write:webhook:bitbucket</li>
|
>
|
||||||
</ul>
|
<ExternalLink className="w-fit text-primary size-4" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
When creating the App Password, ensure you grant the
|
||||||
|
following permissions:
|
||||||
|
<ul className="list-disc list-inside ml-4">
|
||||||
|
<li>Account: Read</li>
|
||||||
|
<li>Workspace membership: Read</li>
|
||||||
|
<li>Projects: Read</li>
|
||||||
|
<li>Repositories: Read</li>
|
||||||
|
<li>Pull requests: Read</li>
|
||||||
|
<li>Webhooks: Read and write</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
After creating, you'll receive an App Password. Copy it and
|
||||||
|
paste it below along with your Bitbucket username.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -154,7 +152,7 @@ export const AddBitbucketProvider = () => {
|
|||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Your Bitbucket Provider, eg: my-personal-account"
|
placeholder="Random Name eg(my-personal-account)"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -181,27 +179,14 @@ export const AddBitbucketProvider = () => {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Bitbucket Email</FormLabel>
|
<FormLabel>App Password</FormLabel>
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Your Bitbucket email" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="apiToken"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>API Token</FormLabel>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Paste your Bitbucket API token"
|
type="password"
|
||||||
|
placeholder="ATBBPDYUC94nR96Nj7Cqpp4pfwKk03573DD2"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -215,7 +200,7 @@ export const AddBitbucketProvider = () => {
|
|||||||
name="workspaceName"
|
name="workspaceName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Workspace Name (optional)</FormLabel>
|
<FormLabel>Workspace Name (Optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="For organization accounts"
|
placeholder="For organization accounts"
|
||||||
|
|||||||
@@ -33,10 +33,7 @@ const Schema = z.object({
|
|||||||
username: z.string().min(1, {
|
username: z.string().min(1, {
|
||||||
message: "Username is required",
|
message: "Username is required",
|
||||||
}),
|
}),
|
||||||
email: z.string().email().optional(),
|
|
||||||
workspaceName: z.string().optional(),
|
workspaceName: z.string().optional(),
|
||||||
apiToken: z.string().optional(),
|
|
||||||
appPassword: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Schema = z.infer<typeof Schema>;
|
type Schema = z.infer<typeof Schema>;
|
||||||
@@ -63,28 +60,19 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
|
|||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
username: "",
|
||||||
email: "",
|
|
||||||
workspaceName: "",
|
workspaceName: "",
|
||||||
apiToken: "",
|
|
||||||
appPassword: "",
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const username = form.watch("username");
|
const username = form.watch("username");
|
||||||
const email = form.watch("email");
|
|
||||||
const workspaceName = form.watch("workspaceName");
|
const workspaceName = form.watch("workspaceName");
|
||||||
const apiToken = form.watch("apiToken");
|
|
||||||
const appPassword = form.watch("appPassword");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
form.reset({
|
||||||
username: bitbucket?.bitbucketUsername || "",
|
username: bitbucket?.bitbucketUsername || "",
|
||||||
email: bitbucket?.bitbucketEmail || "",
|
|
||||||
workspaceName: bitbucket?.bitbucketWorkspaceName || "",
|
workspaceName: bitbucket?.bitbucketWorkspaceName || "",
|
||||||
name: bitbucket?.gitProvider.name || "",
|
name: bitbucket?.gitProvider.name || "",
|
||||||
apiToken: bitbucket?.apiToken || "",
|
|
||||||
appPassword: bitbucket?.appPassword || "",
|
|
||||||
});
|
});
|
||||||
}, [form, isOpen, bitbucket]);
|
}, [form, isOpen, bitbucket]);
|
||||||
|
|
||||||
@@ -93,11 +81,8 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
|
|||||||
bitbucketId,
|
bitbucketId,
|
||||||
gitProviderId: bitbucket?.gitProviderId || "",
|
gitProviderId: bitbucket?.gitProviderId || "",
|
||||||
bitbucketUsername: data.username,
|
bitbucketUsername: data.username,
|
||||||
bitbucketEmail: data.email || "",
|
|
||||||
bitbucketWorkspaceName: data.workspaceName || "",
|
bitbucketWorkspaceName: data.workspaceName || "",
|
||||||
name: data.name || "",
|
name: data.name || "",
|
||||||
apiToken: data.apiToken || "",
|
|
||||||
appPassword: data.appPassword || "",
|
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await utils.gitProvider.getAll.invalidate();
|
await utils.gitProvider.getAll.invalidate();
|
||||||
@@ -136,12 +121,6 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
|
|||||||
>
|
>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Update your Bitbucket authentication. Use API Token for
|
|
||||||
enhanced security (recommended) or App Password for legacy
|
|
||||||
support.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
@@ -175,24 +154,6 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email (Required for API Tokens)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="Your Bitbucket email address"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="workspaceName"
|
name="workspaceName"
|
||||||
@@ -210,49 +171,6 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 border-t pt-4">
|
|
||||||
<h3 className="text-sm font-medium mb-2">
|
|
||||||
Authentication (Update to use API Token)
|
|
||||||
</h3>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="apiToken"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>API Token (Recommended)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter your Bitbucket API Token"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="appPassword"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
App Password (Legacy - will be deprecated June 2026)
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter your Bitbucket App Password"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full justify-between gap-4 mt-4">
|
<div className="flex w-full justify-between gap-4 mt-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -262,10 +180,7 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
|
|||||||
await testConnection({
|
await testConnection({
|
||||||
bitbucketId,
|
bitbucketId,
|
||||||
bitbucketUsername: username,
|
bitbucketUsername: username,
|
||||||
bitbucketEmail: email,
|
|
||||||
workspaceName: workspaceName,
|
workspaceName: workspaceName,
|
||||||
apiToken: apiToken,
|
|
||||||
appPassword: appPassword,
|
|
||||||
})
|
})
|
||||||
.then(async (message) => {
|
.then(async (message) => {
|
||||||
toast.info(`Message: ${message}`);
|
toast.info(`Message: ${message}`);
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ const Schema = z.object({
|
|||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
message: "Name is required",
|
message: "Name is required",
|
||||||
}),
|
}),
|
||||||
appName: z.string().min(1, {
|
|
||||||
message: "App Name is required",
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Schema = z.infer<typeof Schema>;
|
type Schema = z.infer<typeof Schema>;
|
||||||
@@ -58,7 +55,6 @@ export const EditGithubProvider = ({ githubId }: Props) => {
|
|||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
appName: "",
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
});
|
});
|
||||||
@@ -66,7 +62,6 @@ export const EditGithubProvider = ({ githubId }: Props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: github?.gitProvider.name || "",
|
name: github?.gitProvider.name || "",
|
||||||
appName: github?.githubAppName || "",
|
|
||||||
});
|
});
|
||||||
}, [form, isOpen]);
|
}, [form, isOpen]);
|
||||||
|
|
||||||
@@ -75,7 +70,6 @@ export const EditGithubProvider = ({ githubId }: Props) => {
|
|||||||
githubId,
|
githubId,
|
||||||
name: data.name || "",
|
name: data.name || "",
|
||||||
gitProviderId: github?.gitProviderId || "",
|
gitProviderId: github?.gitProviderId || "",
|
||||||
githubAppName: data.appName || "",
|
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await utils.gitProvider.getAll.invalidate();
|
await utils.gitProvider.getAll.invalidate();
|
||||||
@@ -130,22 +124,6 @@ export const EditGithubProvider = ({ githubId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="appName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>App Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="pp Name eg(my-personal)"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex w-full justify-between gap-4 mt-4">
|
<div className="flex w-full justify-between gap-4 mt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -157,13 +157,7 @@ export const ShowGitProviders = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-1 items-center">
|
<div className="flex flex-row gap-1">
|
||||||
{isBitbucket &&
|
|
||||||
gitProvider.bitbucket?.appPassword &&
|
|
||||||
!gitProvider.bitbucket?.apiToken ? (
|
|
||||||
<Badge variant="yellow">Deprecated</Badge>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!haveGithubRequirements && isGithub && (
|
{!haveGithubRequirements && isGithub && (
|
||||||
<div className="flex flex-row gap-1 items-center">
|
<div className="flex flex-row gap-1 items-center">
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Mail,
|
||||||
|
MessageCircleMore,
|
||||||
|
PenBoxIcon,
|
||||||
|
PlusIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
DiscordIcon,
|
DiscordIcon,
|
||||||
GotifyIcon,
|
|
||||||
LarkIcon,
|
|
||||||
NtfyIcon,
|
|
||||||
SlackIcon,
|
SlackIcon,
|
||||||
TelegramIcon,
|
TelegramIcon,
|
||||||
} from "@/components/icons/notification-icons";
|
} from "@/components/icons/notification-icons";
|
||||||
@@ -98,21 +101,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
|||||||
decoration: z.boolean().default(true),
|
decoration: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
.merge(notificationBaseSchema),
|
.merge(notificationBaseSchema),
|
||||||
z
|
|
||||||
.object({
|
|
||||||
type: z.literal("ntfy"),
|
|
||||||
serverUrl: z.string().min(1, { message: "Server URL is required" }),
|
|
||||||
topic: z.string().min(1, { message: "Topic is required" }),
|
|
||||||
accessToken: z.string().min(1, { message: "Access Token is required" }),
|
|
||||||
priority: z.number().min(1).max(5).default(3),
|
|
||||||
})
|
|
||||||
.merge(notificationBaseSchema),
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
type: z.literal("lark"),
|
|
||||||
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
|
||||||
})
|
|
||||||
.merge(notificationBaseSchema),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const notificationsMap = {
|
export const notificationsMap = {
|
||||||
@@ -128,22 +116,14 @@ export const notificationsMap = {
|
|||||||
icon: <DiscordIcon />,
|
icon: <DiscordIcon />,
|
||||||
label: "Discord",
|
label: "Discord",
|
||||||
},
|
},
|
||||||
lark: {
|
|
||||||
icon: <LarkIcon className="text-muted-foreground" />,
|
|
||||||
label: "Lark",
|
|
||||||
},
|
|
||||||
email: {
|
email: {
|
||||||
icon: <Mail size={29} className="text-muted-foreground" />,
|
icon: <Mail size={29} className="text-muted-foreground" />,
|
||||||
label: "Email",
|
label: "Email",
|
||||||
},
|
},
|
||||||
gotify: {
|
gotify: {
|
||||||
icon: <GotifyIcon />,
|
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
|
||||||
label: "Gotify",
|
label: "Gotify",
|
||||||
},
|
},
|
||||||
ntfy: {
|
|
||||||
icon: <NtfyIcon />,
|
|
||||||
label: "ntfy",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
||||||
@@ -175,10 +155,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
api.notification.testEmailConnection.useMutation();
|
api.notification.testEmailConnection.useMutation();
|
||||||
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
|
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
|
||||||
api.notification.testGotifyConnection.useMutation();
|
api.notification.testGotifyConnection.useMutation();
|
||||||
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
|
|
||||||
api.notification.testNtfyConnection.useMutation();
|
|
||||||
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
|
||||||
api.notification.testLarkConnection.useMutation();
|
|
||||||
const slackMutation = notificationId
|
const slackMutation = notificationId
|
||||||
? api.notification.updateSlack.useMutation()
|
? api.notification.updateSlack.useMutation()
|
||||||
: api.notification.createSlack.useMutation();
|
: api.notification.createSlack.useMutation();
|
||||||
@@ -194,12 +170,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
const gotifyMutation = notificationId
|
const gotifyMutation = notificationId
|
||||||
? api.notification.updateGotify.useMutation()
|
? api.notification.updateGotify.useMutation()
|
||||||
: api.notification.createGotify.useMutation();
|
: api.notification.createGotify.useMutation();
|
||||||
const ntfyMutation = notificationId
|
|
||||||
? api.notification.updateNtfy.useMutation()
|
|
||||||
: api.notification.createNtfy.useMutation();
|
|
||||||
const larkMutation = notificationId
|
|
||||||
? api.notification.updateLark.useMutation()
|
|
||||||
: api.notification.createLark.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<NotificationSchema>({
|
const form = useForm<NotificationSchema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -218,10 +188,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type === "email" && fields.length === 0) {
|
if (type === "email") {
|
||||||
append("");
|
append("");
|
||||||
}
|
}
|
||||||
}, [type, append, fields.length]);
|
}, [type, append]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (notification) {
|
if (notification) {
|
||||||
@@ -296,33 +266,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
name: notification.name,
|
name: notification.name,
|
||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
});
|
});
|
||||||
} else if (notification.notificationType === "ntfy") {
|
|
||||||
form.reset({
|
|
||||||
appBuildError: notification.appBuildError,
|
|
||||||
appDeploy: notification.appDeploy,
|
|
||||||
dokployRestart: notification.dokployRestart,
|
|
||||||
databaseBackup: notification.databaseBackup,
|
|
||||||
type: notification.notificationType,
|
|
||||||
accessToken: notification.ntfy?.accessToken,
|
|
||||||
topic: notification.ntfy?.topic,
|
|
||||||
priority: notification.ntfy?.priority,
|
|
||||||
serverUrl: notification.ntfy?.serverUrl,
|
|
||||||
name: notification.name,
|
|
||||||
dockerCleanup: notification.dockerCleanup,
|
|
||||||
serverThreshold: notification.serverThreshold,
|
|
||||||
});
|
|
||||||
} else if (notification.notificationType === "lark") {
|
|
||||||
form.reset({
|
|
||||||
appBuildError: notification.appBuildError,
|
|
||||||
appDeploy: notification.appDeploy,
|
|
||||||
dokployRestart: notification.dokployRestart,
|
|
||||||
databaseBackup: notification.databaseBackup,
|
|
||||||
type: notification.notificationType,
|
|
||||||
webhookUrl: notification.lark?.webhookUrl,
|
|
||||||
name: notification.name,
|
|
||||||
dockerCleanup: notification.dockerCleanup,
|
|
||||||
serverThreshold: notification.serverThreshold,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -335,8 +278,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
discord: discordMutation,
|
discord: discordMutation,
|
||||||
email: emailMutation,
|
email: emailMutation,
|
||||||
gotify: gotifyMutation,
|
gotify: gotifyMutation,
|
||||||
ntfy: ntfyMutation,
|
|
||||||
lark: larkMutation,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: NotificationSchema) => {
|
const onSubmit = async (data: NotificationSchema) => {
|
||||||
@@ -425,34 +366,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
notificationId: notificationId || "",
|
notificationId: notificationId || "",
|
||||||
gotifyId: notification?.gotifyId || "",
|
gotifyId: notification?.gotifyId || "",
|
||||||
});
|
});
|
||||||
} else if (data.type === "ntfy") {
|
|
||||||
promise = ntfyMutation.mutateAsync({
|
|
||||||
appBuildError: appBuildError,
|
|
||||||
appDeploy: appDeploy,
|
|
||||||
dokployRestart: dokployRestart,
|
|
||||||
databaseBackup: databaseBackup,
|
|
||||||
serverUrl: data.serverUrl,
|
|
||||||
accessToken: data.accessToken,
|
|
||||||
topic: data.topic,
|
|
||||||
priority: data.priority,
|
|
||||||
name: data.name,
|
|
||||||
dockerCleanup: dockerCleanup,
|
|
||||||
notificationId: notificationId || "",
|
|
||||||
ntfyId: notification?.ntfyId || "",
|
|
||||||
});
|
|
||||||
} else if (data.type === "lark") {
|
|
||||||
promise = larkMutation.mutateAsync({
|
|
||||||
appBuildError: appBuildError,
|
|
||||||
appDeploy: appDeploy,
|
|
||||||
dokployRestart: dokployRestart,
|
|
||||||
databaseBackup: databaseBackup,
|
|
||||||
webhookUrl: data.webhookUrl,
|
|
||||||
name: data.name,
|
|
||||||
dockerCleanup: dockerCleanup,
|
|
||||||
notificationId: notificationId || "",
|
|
||||||
larkId: notification?.larkId || "",
|
|
||||||
serverThreshold: serverThreshold,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (promise) {
|
if (promise) {
|
||||||
@@ -541,7 +454,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor={key}
|
htmlFor={key}
|
||||||
className="h-24 flex flex-col gap-2 items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
className="flex flex-col gap-2 items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
||||||
>
|
>
|
||||||
{value.icon}
|
{value.icon}
|
||||||
{value.label}
|
{value.label}
|
||||||
@@ -962,104 +875,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{type === "ntfy" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="serverUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Server URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="https://ntfy.sh" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="topic"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Topic</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="deployments" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="accessToken"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Access Token</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="AzxcvbnmKjhgfdsa..."
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="priority"
|
|
||||||
defaultValue={3}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
<FormLabel>Priority</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="3"
|
|
||||||
{...field}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
if (value) {
|
|
||||||
const port = Number.parseInt(value);
|
|
||||||
if (port > 0 && port <= 5) {
|
|
||||||
field.onChange(port);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="number"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Message priority (1-5, default: 3)
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{type === "lark" && (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="webhookUrl"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Webhook URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="https://open.larksuite.com/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -1209,9 +1024,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
isLoadingTelegram ||
|
isLoadingTelegram ||
|
||||||
isLoadingDiscord ||
|
isLoadingDiscord ||
|
||||||
isLoadingEmail ||
|
isLoadingEmail ||
|
||||||
isLoadingGotify ||
|
isLoadingGotify
|
||||||
isLoadingNtfy ||
|
|
||||||
isLoadingLark
|
|
||||||
}
|
}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -1248,17 +1061,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
priority: form.getValues("priority"),
|
priority: form.getValues("priority"),
|
||||||
decoration: form.getValues("decoration"),
|
decoration: form.getValues("decoration"),
|
||||||
});
|
});
|
||||||
} else if (type === "ntfy") {
|
|
||||||
await testNtfyConnection({
|
|
||||||
serverUrl: form.getValues("serverUrl"),
|
|
||||||
topic: form.getValues("topic"),
|
|
||||||
accessToken: form.getValues("accessToken"),
|
|
||||||
priority: form.getValues("priority"),
|
|
||||||
});
|
|
||||||
} else if (type === "lark") {
|
|
||||||
await testLarkConnection({
|
|
||||||
webhookUrl: form.getValues("webhookUrl"),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
toast.success("Connection Success");
|
toast.success("Connection Success");
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
|
import { Bell, Loader2, Mail, MessageCircleMore, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
DiscordIcon,
|
DiscordIcon,
|
||||||
GotifyIcon,
|
|
||||||
LarkIcon,
|
|
||||||
NtfyIcon,
|
|
||||||
SlackIcon,
|
SlackIcon,
|
||||||
TelegramIcon,
|
TelegramIcon,
|
||||||
} from "@/components/icons/notification-icons";
|
} from "@/components/icons/notification-icons";
|
||||||
@@ -36,7 +33,7 @@ export const ShowNotifications = () => {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Add your providers to receive notifications, like Discord, Slack,
|
Add your providers to receive notifications, like Discord, Slack,
|
||||||
Telegram, Email, Lark.
|
Telegram, Email.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
@@ -88,17 +85,7 @@ export const ShowNotifications = () => {
|
|||||||
)}
|
)}
|
||||||
{notification.notificationType === "gotify" && (
|
{notification.notificationType === "gotify" && (
|
||||||
<div className="flex items-center justify-center rounded-lg ">
|
<div className="flex items-center justify-center rounded-lg ">
|
||||||
<GotifyIcon className="size-6" />
|
<MessageCircleMore className="size-6 text-muted-foreground" />
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{notification.notificationType === "ntfy" && (
|
|
||||||
<div className="flex items-center justify-center rounded-lg ">
|
|
||||||
<NtfyIcon className="size-6" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{notification.notificationType === "lark" && (
|
|
||||||
<div className="flex items-center justify-center rounded-lg">
|
|
||||||
<LarkIcon className="size-7 text-muted-foreground" />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,429 +0,0 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import copy from "copy-to-clipboard";
|
|
||||||
import {
|
|
||||||
CopyIcon,
|
|
||||||
DownloadIcon,
|
|
||||||
KeyRound,
|
|
||||||
RefreshCw,
|
|
||||||
ShieldOff,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import {
|
|
||||||
BACKUP_CODES_PLACEHOLDER,
|
|
||||||
backupCodeTemplate,
|
|
||||||
DATE_PLACEHOLDER,
|
|
||||||
USERNAME_PLACEHOLDER,
|
|
||||||
} from "./enable-2fa";
|
|
||||||
|
|
||||||
const PasswordSchema = z.object({
|
|
||||||
password: z.string().min(8, {
|
|
||||||
message: "Password is required",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type PasswordForm = z.infer<typeof PasswordSchema>;
|
|
||||||
type Step = "password" | "actions" | "backup-codes";
|
|
||||||
|
|
||||||
export const Configure2FA = () => {
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { data: currentUser } = api.user.get.useQuery();
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
||||||
const [step, setStep] = useState<Step>("password");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
|
||||||
const [showDisableConfirm, setShowDisableConfirm] = useState(false);
|
|
||||||
const [isDisabling, setIsDisabling] = useState(false);
|
|
||||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<PasswordForm>({
|
|
||||||
resolver: zodResolver(PasswordSchema),
|
|
||||||
defaultValues: {
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDialogOpen) {
|
|
||||||
setStep("password");
|
|
||||||
setPassword("");
|
|
||||||
setBackupCodes([]);
|
|
||||||
form.reset();
|
|
||||||
}
|
|
||||||
}, [isDialogOpen, form]);
|
|
||||||
|
|
||||||
const handlePasswordSubmit = async (formData: PasswordForm) => {
|
|
||||||
setIsRegenerating(true);
|
|
||||||
try {
|
|
||||||
// Verify password by attempting to generate backup codes
|
|
||||||
// This validates the password and checks if 2FA is enabled
|
|
||||||
const result = await authClient.twoFactor.generateBackupCodes({
|
|
||||||
password: formData.password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
form.setError("password", { message: result.error.message });
|
|
||||||
toast.error(result.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we get here, password is correct
|
|
||||||
setPassword(formData.password);
|
|
||||||
setStep("actions");
|
|
||||||
} catch (error) {
|
|
||||||
form.setError("password", {
|
|
||||||
message: error instanceof Error ? error.message : "Incorrect password",
|
|
||||||
});
|
|
||||||
toast.error("Incorrect password");
|
|
||||||
} finally {
|
|
||||||
setIsRegenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegenerateBackupCodes = async () => {
|
|
||||||
setIsRegenerating(true);
|
|
||||||
try {
|
|
||||||
const result = await authClient.twoFactor.generateBackupCodes({
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.data?.backupCodes) {
|
|
||||||
setBackupCodes(result.data.backupCodes);
|
|
||||||
setStep("backup-codes");
|
|
||||||
toast.success("Backup codes regenerated successfully");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to regenerate backup codes",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsRegenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDisable2FA = async () => {
|
|
||||||
setIsDisabling(true);
|
|
||||||
try {
|
|
||||||
const result = await authClient.twoFactor.disable({
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
toast.error(result.error.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("2FA disabled successfully");
|
|
||||||
utils.user.get.invalidate();
|
|
||||||
setIsDialogOpen(false);
|
|
||||||
setShowDisableConfirm(false);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Failed to disable 2FA. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setIsDisabling(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseDialog = () => {
|
|
||||||
if (step === "backup-codes") {
|
|
||||||
setStep("actions");
|
|
||||||
} else {
|
|
||||||
setIsDialogOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadBackupCodes = () => {
|
|
||||||
if (!backupCodes || backupCodes.length === 0) {
|
|
||||||
toast.error("No backup codes to download.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const backupCodesFormatted = backupCodes
|
|
||||||
.map((code, index) => ` ${index + 1}. ${code}`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const date = new Date();
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(date.getDate()).padStart(2, "0");
|
|
||||||
const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`;
|
|
||||||
|
|
||||||
const backupCodesText = backupCodeTemplate
|
|
||||||
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
|
|
||||||
.replace(DATE_PLACEHOLDER, date.toLocaleString())
|
|
||||||
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
|
|
||||||
|
|
||||||
const blob = new Blob([backupCodesText], { type: "text/plain" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyBackupCodes = () => {
|
|
||||||
const date = new Date();
|
|
||||||
|
|
||||||
const backupCodesFormatted = backupCodes
|
|
||||||
.map((code, index) => ` ${index + 1}. ${code}`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const backupCodesText = backupCodeTemplate
|
|
||||||
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
|
|
||||||
.replace(DATE_PLACEHOLDER, date.toLocaleString())
|
|
||||||
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
|
|
||||||
|
|
||||||
copy(backupCodesText);
|
|
||||||
toast.success("Backup codes copied to clipboard");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="secondary">
|
|
||||||
<KeyRound className="size-4 text-muted-foreground" />
|
|
||||||
Manage 2FA
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{step === "password" && "Verify Your Identity"}
|
|
||||||
{step === "actions" && "2FA Configuration"}
|
|
||||||
{step === "backup-codes" && "New Backup Codes"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{step === "password" &&
|
|
||||||
"Enter your password to manage your 2FA settings"}
|
|
||||||
{step === "actions" &&
|
|
||||||
"Choose an action to manage your two-factor authentication"}
|
|
||||||
{step === "backup-codes" &&
|
|
||||||
"Save these backup codes in a secure place"}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{step === "password" && (
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(handlePasswordSubmit)}
|
|
||||||
className="space-y-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="password"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Password</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Enter your password to continue
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsDialogOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" isLoading={isRegenerating}>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "actions" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<div className="flex flex-col gap-2 p-4 border rounded-lg hover:bg-muted/50 transition-colors">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="font-medium flex items-center gap-2">
|
|
||||||
<RefreshCw className="size-4" />
|
|
||||||
Regenerate Backup Codes
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Generate new backup codes to replace your existing ones.
|
|
||||||
This will invalidate all previous backup codes.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleRegenerateBackupCodes}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full mt-2"
|
|
||||||
isLoading={isRegenerating}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-4 mr-2" />
|
|
||||||
Regenerate Backup Codes
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 p-4 border border-destructive/50 rounded-lg hover:bg-destructive/5 transition-colors">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="font-medium flex items-center gap-2 text-destructive">
|
|
||||||
<ShieldOff className="size-4" />
|
|
||||||
Disable 2FA
|
|
||||||
</h4>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Completely disable two-factor authentication for your
|
|
||||||
account. This will make your account less secure.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => setShowDisableConfirm(true)}
|
|
||||||
variant="destructive"
|
|
||||||
className="w-full mt-2"
|
|
||||||
>
|
|
||||||
<ShieldOff className="size-4 mr-2" />
|
|
||||||
Disable 2FA
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsDialogOpen(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "backup-codes" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="w-full space-y-3 border rounded-lg p-4 bg-muted/50">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{backupCodes.map((code, index) => (
|
|
||||||
<code
|
|
||||||
key={index}
|
|
||||||
className="bg-background p-2 rounded text-sm font-mono text-center"
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</code>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Save these backup codes in a secure place. You can use them to
|
|
||||||
access your account if you lose access to your authenticator
|
|
||||||
device. Each code can only be used once.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleDownloadBackupCodes}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<DownloadIcon className="size-4 mr-2" />
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleCopyBackupCodes}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<CopyIcon className="size-4 mr-2" />
|
|
||||||
Copy
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-4">
|
|
||||||
<Button variant="outline" onClick={handleCloseDialog}>
|
|
||||||
Back to Actions
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => setIsDialogOpen(false)}>Done</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={showDisableConfirm}
|
|
||||||
onOpenChange={setShowDisableConfirm}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will permanently disable Two-Factor Authentication for your
|
|
||||||
account. Your account will be less secure without 2FA enabled.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleDisable2FA}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
disabled={isDisabling}
|
|
||||||
>
|
|
||||||
{isDisabling ? "Disabling..." : "Disable 2FA"}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const PasswordSchema = z.object({
|
||||||
|
password: z.string().min(8, {
|
||||||
|
message: "Password is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type PasswordForm = z.infer<typeof PasswordSchema>;
|
||||||
|
|
||||||
|
export const Disable2FA = () => {
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<PasswordForm>({
|
||||||
|
resolver: zodResolver(PasswordSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (formData: PasswordForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.twoFactor.disable({
|
||||||
|
password: formData.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
form.setError("password", {
|
||||||
|
message: result.error.message,
|
||||||
|
});
|
||||||
|
toast.error(result.error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("2FA disabled successfully");
|
||||||
|
utils.user.get.invalidate();
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch {
|
||||||
|
form.setError("password", {
|
||||||
|
message: "Connection error. Please try again.",
|
||||||
|
});
|
||||||
|
toast.error("Connection error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive">Disable 2FA</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action cannot be undone. This will permanently disable
|
||||||
|
Two-Factor Authentication for your account.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(handleSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Enter your password to disable 2FA
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="destructive" isLoading={isLoading}>
|
||||||
|
Disable 2FA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import copy from "copy-to-clipboard";
|
import { Fingerprint, QrCode } from "lucide-react";
|
||||||
import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react";
|
|
||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -30,12 +29,6 @@ import {
|
|||||||
InputOTPGroup,
|
InputOTPGroup,
|
||||||
InputOTPSlot,
|
InputOTPSlot,
|
||||||
} from "@/components/ui/input-otp";
|
} from "@/components/ui/input-otp";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
@@ -61,26 +54,6 @@ type TwoFactorSetupData = {
|
|||||||
type PasswordForm = z.infer<typeof PasswordSchema>;
|
type PasswordForm = z.infer<typeof PasswordSchema>;
|
||||||
type PinForm = z.infer<typeof PinSchema>;
|
type PinForm = z.infer<typeof PinSchema>;
|
||||||
|
|
||||||
export const USERNAME_PLACEHOLDER = "%username%";
|
|
||||||
export const DATE_PLACEHOLDER = "%date%";
|
|
||||||
export const BACKUP_CODES_PLACEHOLDER = "%backupCodes%";
|
|
||||||
|
|
||||||
export const backupCodeTemplate = `Dokploy - BACKUP VERIFICATION CODES
|
|
||||||
|
|
||||||
Points to note
|
|
||||||
--------------
|
|
||||||
# Each code can be used only once.
|
|
||||||
# Do not share these codes with anyone.
|
|
||||||
|
|
||||||
Generated codes
|
|
||||||
---------------
|
|
||||||
Username: ${USERNAME_PLACEHOLDER}
|
|
||||||
Generated on: ${DATE_PLACEHOLDER}
|
|
||||||
|
|
||||||
|
|
||||||
${BACKUP_CODES_PLACEHOLDER}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const Enable2FA = () => {
|
export const Enable2FA = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [data, setData] = useState<TwoFactorSetupData | null>(null);
|
const [data, setData] = useState<TwoFactorSetupData | null>(null);
|
||||||
@@ -89,7 +62,6 @@ export const Enable2FA = () => {
|
|||||||
const [step, setStep] = useState<"password" | "verify">("password");
|
const [step, setStep] = useState<"password" | "verify">("password");
|
||||||
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||||
const [otpValue, setOtpValue] = useState("");
|
const [otpValue, setOtpValue] = useState("");
|
||||||
const { data: currentUser } = api.user.get.useQuery();
|
|
||||||
|
|
||||||
const handleVerifySubmit = async (e: React.FormEvent) => {
|
const handleVerifySubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -206,54 +178,6 @@ export const Enable2FA = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadBackupCodes = () => {
|
|
||||||
if (!backupCodes || backupCodes.length === 0) {
|
|
||||||
toast.error("No backup codes to download.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const backupCodesFormatted = backupCodes
|
|
||||||
.map((code, index) => ` ${index + 1}. ${code}`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const date = new Date();
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(date.getDate()).padStart(2, "0");
|
|
||||||
const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`;
|
|
||||||
|
|
||||||
const backupCodesText = backupCodeTemplate
|
|
||||||
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
|
|
||||||
.replace(DATE_PLACEHOLDER, date.toLocaleString())
|
|
||||||
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
|
|
||||||
|
|
||||||
const blob = new Blob([backupCodesText], { type: "text/plain" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = filename;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyBackupCodes = () => {
|
|
||||||
const date = new Date();
|
|
||||||
|
|
||||||
const backupCodesFormatted = backupCodes
|
|
||||||
.map((code, index) => ` ${index + 1}. ${code}`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const backupCodesText = backupCodeTemplate
|
|
||||||
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
|
|
||||||
.replace(DATE_PLACEHOLDER, date.toLocaleString())
|
|
||||||
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
|
|
||||||
|
|
||||||
copy(backupCodesText);
|
|
||||||
toast.success("Backup codes copied to clipboard");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -340,7 +264,6 @@ export const Enable2FA = () => {
|
|||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
Scan this QR code with your authenticator app
|
Scan this QR code with your authenticator app
|
||||||
</span>
|
</span>
|
||||||
{/** biome-ignore lint/performance/noImgElement: This is a valid use case for an img element */}
|
|
||||||
<img
|
<img
|
||||||
src={data.qrCodeUrl}
|
src={data.qrCodeUrl}
|
||||||
alt="2FA QR Code"
|
alt="2FA QR Code"
|
||||||
@@ -358,46 +281,7 @@ export const Enable2FA = () => {
|
|||||||
|
|
||||||
{backupCodes && backupCodes.length > 0 && (
|
{backupCodes && backupCodes.length > 0 && (
|
||||||
<div className="w-full space-y-3 border rounded-lg p-4">
|
<div className="w-full space-y-3 border rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between">
|
<h4 className="font-medium">Backup Codes</h4>
|
||||||
<h4 className="font-medium">Backup Codes</h4>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleCopyBackupCodes}
|
|
||||||
>
|
|
||||||
<CopyIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Copy</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleDownloadBackupCodes}
|
|
||||||
>
|
|
||||||
<DownloadIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Download</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{backupCodes.map((code, index) => (
|
{backupCodes.map((code, index) => (
|
||||||
<code
|
<code
|
||||||
|
|||||||
@@ -29,14 +29,11 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { Configure2FA } from "./configure-2fa";
|
import { Disable2FA } from "./disable-2fa";
|
||||||
import { Enable2FA } from "./enable-2fa";
|
import { Enable2FA } from "./enable-2fa";
|
||||||
|
|
||||||
const profileSchema = z.object({
|
const profileSchema = z.object({
|
||||||
email: z
|
email: z.string(),
|
||||||
.string()
|
|
||||||
.email("Please enter a valid email address")
|
|
||||||
.min(1, "Email is required"),
|
|
||||||
password: z.string().nullable(),
|
password: z.string().nullable(),
|
||||||
currentPassword: z.string().nullable(),
|
currentPassword: z.string().nullable(),
|
||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
@@ -62,6 +59,7 @@ const randomImages = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const ProfileForm = () => {
|
export const ProfileForm = () => {
|
||||||
|
const _utils = api.useUtils();
|
||||||
const { data, refetch, isLoading } = api.user.get.useQuery();
|
const { data, refetch, isLoading } = api.user.get.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
@@ -119,27 +117,28 @@ export const ProfileForm = () => {
|
|||||||
}, [form, data]);
|
}, [form, data]);
|
||||||
|
|
||||||
const onSubmit = async (values: Profile) => {
|
const onSubmit = async (values: Profile) => {
|
||||||
try {
|
await mutateAsync({
|
||||||
await mutateAsync({
|
email: values.email.toLowerCase(),
|
||||||
email: values.email.toLowerCase(),
|
password: values.password || undefined,
|
||||||
password: values.password || undefined,
|
image: values.image,
|
||||||
image: values.image,
|
currentPassword: values.currentPassword || undefined,
|
||||||
currentPassword: values.currentPassword || undefined,
|
allowImpersonation: values.allowImpersonation,
|
||||||
allowImpersonation: values.allowImpersonation,
|
name: values.name || undefined,
|
||||||
name: values.name || undefined,
|
})
|
||||||
|
.then(async () => {
|
||||||
|
await refetch();
|
||||||
|
toast.success("Profile Updated");
|
||||||
|
form.reset({
|
||||||
|
email: values.email,
|
||||||
|
password: "",
|
||||||
|
image: values.image,
|
||||||
|
currentPassword: "",
|
||||||
|
name: values.name || "",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error updating the profile");
|
||||||
});
|
});
|
||||||
await refetch();
|
|
||||||
toast.success("Profile Updated");
|
|
||||||
form.reset({
|
|
||||||
email: values.email,
|
|
||||||
password: "",
|
|
||||||
image: values.image,
|
|
||||||
currentPassword: "",
|
|
||||||
name: values.name || "",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
toast.error("Error updating the profile");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -156,8 +155,7 @@ export const ProfileForm = () => {
|
|||||||
{t("settings.profile.description")}
|
{t("settings.profile.description")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Disable2FA />}
|
||||||
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
@@ -256,16 +254,8 @@ export const ProfileForm = () => {
|
|||||||
onValueChange={(e) => {
|
onValueChange={(e) => {
|
||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
}}
|
}}
|
||||||
defaultValue={
|
defaultValue={field.value}
|
||||||
field.value?.startsWith("data:")
|
value={field.value}
|
||||||
? "upload"
|
|
||||||
: field.value
|
|
||||||
}
|
|
||||||
value={
|
|
||||||
field.value?.startsWith("data:")
|
|
||||||
? "upload"
|
|
||||||
: field.value
|
|
||||||
}
|
|
||||||
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
||||||
>
|
>
|
||||||
<FormItem key="no-avatar">
|
<FormItem key="no-avatar">
|
||||||
@@ -286,72 +276,6 @@ export const ProfileForm = () => {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem key="custom-upload">
|
|
||||||
<FormLabel className="[&:has([data-state=checked])>.upload-avatar]:border-primary [&:has([data-state=checked])>.upload-avatar]:border-1 [&:has([data-state=checked])>.upload-avatar]:p-px cursor-pointer">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem
|
|
||||||
value="upload"
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<div
|
|
||||||
className="upload-avatar h-12 w-12 rounded-full border border-dashed border-muted-foreground hover:border-primary transition-colors flex items-center justify-center bg-muted/50 hover:bg-muted overflow-hidden"
|
|
||||||
onClick={() =>
|
|
||||||
document
|
|
||||||
.getElementById("avatar-upload")
|
|
||||||
?.click()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{field.value?.startsWith("data:") ? (
|
|
||||||
// biome-ignore lint/performance/noImgElement: this is an justified use of img element
|
|
||||||
<img
|
|
||||||
src={field.value}
|
|
||||||
alt="Custom avatar"
|
|
||||||
className="h-full w-full object-cover rounded-full"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5 text-muted-foreground"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="avatar-upload"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={async (e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
// max file size 2mb
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
|
||||||
toast.error(
|
|
||||||
"Image size must be less than 2MB",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
const result = event.target
|
|
||||||
?.result as string;
|
|
||||||
field.onChange(result);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
{availableAvatars.map((image) => (
|
{availableAvatars.map((image) => (
|
||||||
<FormItem key={image}>
|
<FormItem key={image}>
|
||||||
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
|
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
|
||||||
@@ -362,7 +286,6 @@ export const ProfileForm = () => {
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
{/* biome-ignore lint/performance/noImgElement: this is an justified use of img element */}
|
|
||||||
<img
|
<img
|
||||||
key={image}
|
key={image}
|
||||||
src={image}
|
src={image}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user