Compare commits

..

9 Commits

193 changed files with 38946 additions and 57304 deletions

View File

@@ -77,10 +77,6 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<div>
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
</a>
</div>
<!-- Premium Supporters 🥇 -->

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllProperties } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile1 = `
version: "3.8"
@@ -61,7 +61,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile1 = parse(`
const expectedComposeFile1 = load(`
version: "3.8"
services:
@@ -120,7 +120,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 1", () => {
const composeData = parse(composeFile1) as ComposeSpecification;
const composeData = load(composeFile1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -185,7 +185,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile2 = parse(`
const expectedComposeFile2 = load(`
version: "3.8"
services:
@@ -243,7 +243,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 2", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -308,7 +308,7 @@ secrets:
file: ./service_secret.txt
`;
const expectedComposeFile3 = parse(`
const expectedComposeFile3 = load(`
version: "3.8"
services:
@@ -366,7 +366,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 3", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -420,7 +420,7 @@ volumes:
driver: local
`;
const expectedComposeFile = parse(`
const expectedComposeFile = load(`
version: "3.8"
services:
@@ -467,7 +467,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to all properties in Plausible compose file", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -23,7 +23,7 @@ configs:
`;
test("Add suffix to configs in root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -59,7 +59,7 @@ configs:
`;
test("Add suffix to multiple configs in root property", () => {
const composeData = parse(composeFileMultipleConfigs) as ComposeSpecification;
const composeData = load(composeFileMultipleConfigs) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -92,7 +92,7 @@ configs:
`;
test("Add suffix to configs with different properties in root property", () => {
const composeData = parse(
const composeData = load(
composeFileDifferentProperties,
) as ComposeSpecification;
@@ -137,7 +137,7 @@ configs:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigRoot = parse(`
const expectedComposeFileConfigRoot = load(`
version: "3.8"
services:
@@ -162,7 +162,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs in root property", () => {
const composeData = parse(composeFileConfigRoot) as ComposeSpecification;
const composeData = load(composeFileConfigRoot) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -3,8 +3,8 @@ import {
addSuffixToConfigsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -22,7 +22,7 @@ configs:
`;
test("Add suffix to configs in services", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -54,7 +54,7 @@ configs:
`;
test("Add suffix to configs in services with single config", () => {
const composeData = parse(
const composeData = load(
composeFileSingleServiceConfig,
) as ComposeSpecification;
@@ -108,7 +108,7 @@ configs:
`;
test("Add suffix to configs in services with multiple configs", () => {
const composeData = parse(
const composeData = load(
composeFileMultipleServicesConfigs,
) as ComposeSpecification;
@@ -157,7 +157,7 @@ services:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigServices = parse(`
const expectedComposeFileConfigServices = load(`
version: "3.8"
services:
@@ -182,7 +182,7 @@ services:
`) as ComposeSpecification;
test("Add suffix to configs in services", () => {
const composeData = parse(composeFileConfigServices) as ComposeSpecification;
const composeData = load(composeFileConfigServices) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -43,7 +43,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileCombinedConfigs = parse(`
const expectedComposeFileCombinedConfigs = load(`
version: "3.8"
services:
@@ -77,7 +77,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to all configs in root and services", () => {
const composeData = parse(composeFileCombinedConfigs) as ComposeSpecification;
const composeData = load(composeFileCombinedConfigs) as ComposeSpecification;
const suffix = "testhash";
@@ -122,7 +122,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileWithEnvAndExternal = parse(`
const expectedComposeFileWithEnvAndExternal = load(`
version: "3.8"
services:
@@ -159,7 +159,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs with environment and external", () => {
const composeData = parse(
const composeData = load(
composeFileWithEnvAndExternal,
) as ComposeSpecification;
@@ -200,7 +200,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileWithTemplateDriverAndLabels = parse(`
const expectedComposeFileWithTemplateDriverAndLabels = load(`
version: "3.8"
services:
@@ -231,7 +231,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs with template driver and labels", () => {
const composeData = parse(
const composeData = load(
composeFileWithTemplateDriverAndLabels,
) as ComposeSpecification;

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -35,7 +35,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to networks root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -79,7 +79,7 @@ networks:
`;
test("Add suffix to advanced networks root property (2 TRY)", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -120,7 +120,7 @@ networks:
`;
test("Add suffix to networks with external properties", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -160,7 +160,7 @@ networks:
`;
test("Add suffix to networks with IPAM configurations", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -201,7 +201,7 @@ networks:
`;
test("Add suffix to networks with custom options", () => {
const composeData = parse(composeFile5) as ComposeSpecification;
const composeData = load(composeFile5) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -264,7 +264,7 @@ networks:
`;
test("Add suffix to networks with static suffix", () => {
const composeData = parse(composeFile6) as ComposeSpecification;
const composeData = load(composeFile6) as ComposeSpecification;
const suffix = "testhash";
@@ -273,7 +273,7 @@ test("Add suffix to networks with static suffix", () => {
}
const networks = addSuffixToNetworksRoot(composeData.networks, suffix);
const expectedComposeData = parse(
const expectedComposeData = load(
expectedComposeFile6,
) as ComposeSpecification;
expect(networks).toStrictEqual(expectedComposeData.networks);
@@ -293,7 +293,7 @@ networks:
`;
test("It shoudn't add suffix to dokploy-network", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const composeData = load(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -3,8 +3,8 @@ import {
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -23,7 +23,7 @@ services:
`;
test("Add suffix to networks in services", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -67,7 +67,7 @@ networks:
`;
test("Add suffix to networks in services with aliases", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -107,7 +107,7 @@ networks:
`;
test("Add suffix to networks in services (Object with simple networks)", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -153,7 +153,7 @@ networks:
`;
test("Add suffix to networks in services (combined case)", () => {
const composeData = parse(composeFileCombined) as ComposeSpecification;
const composeData = load(composeFileCombined) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -196,7 +196,7 @@ services:
`;
test("It shoudn't add suffix to dokploy-network in services", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const composeData = load(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -245,7 +245,7 @@ services:
`;
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = parse(composeFile8) as ComposeSpecification;
const composeData = load(composeFile8) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -5,8 +5,8 @@ import {
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombined = `
version: "3.8"
@@ -39,7 +39,7 @@ networks:
`;
test("Add suffix to networks in services and root (combined case)", () => {
const composeData = parse(composeFileCombined) as ComposeSpecification;
const composeData = load(composeFileCombined) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -89,7 +89,7 @@ test("Add suffix to networks in services and root (combined case)", () => {
expect(redisNetworks).not.toHaveProperty("backend");
});
const expectedComposeFile = parse(`
const expectedComposeFile = load(`
version: "3.8"
services:
@@ -120,7 +120,7 @@ networks:
`);
test("Add suffix to networks in compose file", () => {
const composeData = parse(composeFileCombined) as ComposeSpecification;
const composeData = load(composeFileCombined) as ComposeSpecification;
const suffix = "testhash";
if (!composeData?.networks) {
@@ -156,7 +156,7 @@ networks:
driver: bridge
`;
const expectedComposeFile2 = parse(`
const expectedComposeFile2 = load(`
version: "3.8"
services:
@@ -182,7 +182,7 @@ networks:
`);
test("Add suffix to networks in compose file with external and internal networks", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
@@ -218,7 +218,7 @@ networks:
com.docker.network.bridge.enable_icc: "true"
`;
const expectedComposeFile3 = parse(`
const expectedComposeFile3 = load(`
version: "3.8"
services:
@@ -247,7 +247,7 @@ networks:
`);
test("Add suffix to networks in compose file with multiple services and complex network configurations", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
@@ -289,7 +289,7 @@ networks:
`;
const expectedComposeFile4 = parse(`
const expectedComposeFile4 = load(`
version: "3.8"
services:
@@ -326,7 +326,7 @@ networks:
`);
test("Expect don't add suffix to dokploy-network in compose file with multiple services and complex network configurations", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -23,7 +23,7 @@ secrets:
`;
test("Add suffix to secrets in root property", () => {
const composeData = parse(composeFileSecretsRoot) as ComposeSpecification;
const composeData = load(composeFileSecretsRoot) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {
@@ -52,7 +52,7 @@ secrets:
`;
test("Add suffix to secrets in root property (Test 1)", () => {
const composeData = parse(composeFileSecretsRoot1) as ComposeSpecification;
const composeData = load(composeFileSecretsRoot1) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {
@@ -84,7 +84,7 @@ secrets:
`;
test("Add suffix to secrets in root property (Test 2)", () => {
const composeData = parse(composeFileSecretsRoot2) as ComposeSpecification;
const composeData = load(composeFileSecretsRoot2) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {

View File

@@ -3,8 +3,8 @@ import {
addSuffixToSecretsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileSecretsServices = `
version: "3.8"
@@ -21,7 +21,7 @@ secrets:
`;
test("Add suffix to secrets in services", () => {
const composeData = parse(composeFileSecretsServices) as ComposeSpecification;
const composeData = load(composeFileSecretsServices) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
@@ -54,9 +54,7 @@ secrets:
`;
test("Add suffix to secrets in services (Test 1)", () => {
const composeData = parse(
composeFileSecretsServices1,
) as ComposeSpecification;
const composeData = load(composeFileSecretsServices1) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
@@ -95,9 +93,7 @@ secrets:
`;
test("Add suffix to secrets in services (Test 2)", () => {
const composeData = parse(
composeFileSecretsServices2,
) as ComposeSpecification;
const composeData = load(composeFileSecretsServices2) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllSecrets } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombinedSecrets = `
version: "3.8"
@@ -25,7 +25,7 @@ secrets:
file: ./app_secret.txt
`;
const expectedComposeFileCombinedSecrets = parse(`
const expectedComposeFileCombinedSecrets = load(`
version: "3.8"
services:
@@ -48,7 +48,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets", () => {
const composeData = parse(composeFileCombinedSecrets) as ComposeSpecification;
const composeData = load(composeFileCombinedSecrets) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
@@ -77,7 +77,7 @@ secrets:
file: ./cache_secret.txt
`;
const expectedComposeFileCombinedSecrets3 = parse(`
const expectedComposeFileCombinedSecrets3 = load(`
version: "3.8"
services:
@@ -99,9 +99,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets (3rd Case)", () => {
const composeData = parse(
composeFileCombinedSecrets3,
) as ComposeSpecification;
const composeData = load(composeFileCombinedSecrets3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
@@ -130,7 +128,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFileCombinedSecrets4 = parse(`
const expectedComposeFileCombinedSecrets4 = load(`
version: "3.8"
services:
@@ -152,9 +150,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets (4th Case)", () => {
const composeData = parse(
composeFileCombinedSecrets4,
) as ComposeSpecification;
const composeData = load(composeFileCombinedSecrets4) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -27,7 +27,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to service names with container_name in compose file", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -32,7 +32,7 @@ networks:
`;
test("Add suffix to service names with depends_on (array) in compose file", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -102,7 +102,7 @@ networks:
`;
test("Add suffix to service names with depends_on (object) in compose file", () => {
const composeData = parse(composeFile5) as ComposeSpecification;
const composeData = load(composeFile5) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -30,7 +30,7 @@ networks:
`;
test("Add suffix to service names with extends (string) in compose file", () => {
const composeData = parse(composeFile6) as ComposeSpecification;
const composeData = load(composeFile6) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -90,7 +90,7 @@ networks:
`;
test("Add suffix to service names with extends (object) in compose file", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const composeData = load(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -31,7 +31,7 @@ networks:
`;
test("Add suffix to service names with links in compose file", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -26,7 +26,7 @@ networks:
`;
test("Add suffix to service names in compose file", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -3,8 +3,8 @@ import {
addSuffixToAllServiceNames,
addSuffixToServiceNames,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombinedAllCases = `
version: "3.8"
@@ -38,7 +38,7 @@ networks:
driver: bridge
`;
const expectedComposeFile = parse(`
const expectedComposeFile = load(`
version: "3.8"
services:
@@ -71,9 +71,7 @@ networks:
`);
test("Add suffix to all service names in compose file", () => {
const composeData = parse(
composeFileCombinedAllCases,
) as ComposeSpecification;
const composeData = load(composeFileCombinedAllCases) as ComposeSpecification;
const suffix = "testhash";
@@ -133,7 +131,7 @@ networks:
driver: bridge
`;
const expectedComposeFile1 = parse(`
const expectedComposeFile1 = load(`
version: "3.8"
services:
@@ -178,7 +176,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 1", () => {
const composeData = parse(composeFile1) as ComposeSpecification;
const composeData = load(composeFile1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
@@ -229,7 +227,7 @@ networks:
driver: bridge
`;
const expectedComposeFile2 = parse(`
const expectedComposeFile2 = load(`
version: "3.8"
services:
@@ -273,7 +271,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 2", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
@@ -324,7 +322,7 @@ networks:
driver: bridge
`;
const expectedComposeFile3 = parse(`
const expectedComposeFile3 = load(`
version: "3.8"
services:
@@ -368,7 +366,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 3", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -35,7 +35,7 @@ networks:
`;
test("Add suffix to service names with volumes_from in compose file", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -4,8 +4,8 @@ import {
addSuffixToVolumesRoot,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
services:
@@ -70,7 +70,7 @@ volumes:
driver: local
`;
const expectedDockerCompose = parse(`
const expectedDockerCompose = load(`
services:
mail:
image: bytemark/smtp
@@ -143,7 +143,7 @@ test("Generate random hash with 8 characters", () => {
// Docker compose needs unique names for services, volumes, networks and containers
// So base on a input which is a dockercompose file, it should replace the name with a hash and return a new dockercompose file
test("Add suffix to volumes root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -165,7 +165,7 @@ test("Add suffix to volumes root property", () => {
});
test("Expect to change the suffix in all the possible places", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -195,7 +195,7 @@ volumes:
mongo-data:
`;
const expectedDockerCompose2 = parse(`
const expectedDockerCompose2 = load(`
version: '3.8'
services:
app:
@@ -218,7 +218,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (2 Try)", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -248,7 +248,7 @@ volumes:
mongo-data:
`;
const expectedDockerCompose3 = parse(`
const expectedDockerCompose3 = load(`
version: '3.8'
services:
app:
@@ -271,7 +271,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (3 Try)", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -645,7 +645,7 @@ volumes:
db-config:
`;
const expectedDockerComposeComplex = parse(`
const expectedDockerComposeComplex = load(`
version: "3.8"
services:
studio:
@@ -1012,7 +1012,7 @@ volumes:
`);
test("Expect to change the suffix in all the possible places (4 Try)", () => {
const composeData = parse(composeFileComplex) as ComposeSpecification;
const composeData = load(composeFileComplex) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1065,7 +1065,7 @@ volumes:
db-data:
`;
const expectedDockerComposeExample1 = parse(`
const expectedDockerComposeExample1 = load(`
version: "3.8"
services:
web:
@@ -1111,7 +1111,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (5 Try)", () => {
const composeData = parse(composeFileExample1) as ComposeSpecification;
const composeData = load(composeFileExample1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1143,7 +1143,7 @@ volumes:
backrest-cache:
`;
const expectedDockerComposeBackrest = parse(`
const expectedDockerComposeBackrest = load(`
services:
backrest:
image: garethgeorge/backrest:v1.7.3
@@ -1168,7 +1168,7 @@ volumes:
`) as ComposeSpecification;
test("Should handle volume paths with subdirectories correctly", () => {
const composeData = parse(composeFileBackrest) as ComposeSpecification;
const composeData = load(composeFileBackrest) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -29,7 +29,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to volumes in root property", () => {
const composeData = parse(composeFile) as ComposeSpecification;
const composeData = load(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -67,7 +67,7 @@ networks:
`;
test("Add suffix to volumes in root property (Case 2)", () => {
const composeData = parse(composeFile2) as ComposeSpecification;
const composeData = load(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -101,7 +101,7 @@ networks:
`;
test("Add suffix to volumes in root property (Case 3)", () => {
const composeData = parse(composeFile3) as ComposeSpecification;
const composeData = load(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -148,7 +148,7 @@ volumes:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFile4 = parse(`
const expectedComposeFile4 = load(`
version: "3.8"
services:
@@ -179,7 +179,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to volumes in root property", () => {
const composeData = parse(composeFile4) as ComposeSpecification;
const composeData = load(composeFile4) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -3,8 +3,8 @@ import {
addSuffixToVolumesInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -24,7 +24,7 @@ services:
`;
test("Add suffix to volumes declared directly in services", () => {
const composeData = parse(composeFile1) as ComposeSpecification;
const composeData = load(composeFile1) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -59,7 +59,7 @@ volumes:
`;
test("Add suffix to volumes declared directly in services (Case 2)", () => {
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllVolumes } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileTypeVolume = `
version: "3.8"
@@ -23,7 +23,7 @@ volumes:
driver: local
`;
const expectedComposeFileTypeVolume = parse(`
const expectedComposeFileTypeVolume = load(`
version: "3.8"
services:
@@ -44,7 +44,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to volumes with type: volume in services", () => {
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const suffix = "testhash";
@@ -73,7 +73,7 @@ volumes:
driver: local
`;
const expectedComposeFileTypeVolume1 = parse(`
const expectedComposeFileTypeVolume1 = load(`
version: "3.8"
services:
@@ -93,7 +93,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to mixed volumes in services", () => {
const composeData = parse(composeFileTypeVolume1) as ComposeSpecification;
const composeData = load(composeFileTypeVolume1) as ComposeSpecification;
const suffix = "testhash";
@@ -128,7 +128,7 @@ volumes:
device: /path/to/app/logs
`;
const expectedComposeFileTypeVolume2 = parse(`
const expectedComposeFileTypeVolume2 = load(`
version: "3.8"
services:
@@ -154,7 +154,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to complex volume configurations in services", () => {
const composeData = parse(composeFileTypeVolume2) as ComposeSpecification;
const composeData = load(composeFileTypeVolume2) as ComposeSpecification;
const suffix = "testhash";
@@ -218,7 +218,7 @@ volumes:
device: /path/to/shared/logs
`;
const expectedComposeFileTypeVolume3 = parse(`
const expectedComposeFileTypeVolume3 = load(`
version: "3.8"
services:
@@ -273,7 +273,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to complex nested volumes configuration in services", () => {
const composeData = parse(composeFileTypeVolume3) as ComposeSpecification;
const composeData = load(composeFileTypeVolume3) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -48,7 +48,6 @@ const baseApp: ApplicationNested = {
dockerBuildStage: "",
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewBuildSecrets: null,
previewCertificateType: "none",
previewCustomCertResolver: null,
previewEnv: null,
@@ -74,7 +73,6 @@ const baseApp: ApplicationNested = {
},
},
buildArgs: null,
buildSecrets: null,
buildPath: "/",
gitlabPathNamespace: "",
buildType: "nixpacks",
@@ -135,7 +133,6 @@ const baseApp: ApplicationNested = {
username: null,
dockerContextPath: null,
rollbackActive: false,
stopGracePeriodSwarm: null,
};
describe("unzipDrop using real zip files", () => {

View File

@@ -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");
});
});

View File

@@ -228,58 +228,5 @@ describe("helpers functions", () => {
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
);
});
it("should handle JWT payload with newlines and whitespace by trimming them", () => {
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
const expiry = iat + 3600;
const payloadWithNewlines = `{
"role": "anon",
"iss": "supabase",
"exp": ${expiry}
}
`;
const jwt = processValue(
"${jwt:secret:payload}",
{
secret: "mysecret",
payload: payloadWithNewlines,
},
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
jwtCheckHeader(parts[0]);
const decodedPayload = jwtBase64Decode(parts[1]);
expect(decodedPayload).toHaveProperty("role");
expect(decodedPayload.role).toEqual("anon");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload.iss).toEqual("supabase");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.exp).toEqual(expiry);
});
it("should handle JWT payload with leading and trailing whitespace", () => {
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
const expiry = iat + 3600;
const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `;
const jwt = processValue(
"${jwt:secret:payload}",
{
secret: "mysecret",
payload: payloadWithWhitespace,
},
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
jwtCheckHeader(parts[0]);
const decodedPayload = jwtBase64Decode(parts[1]);
expect(decodedPayload).toHaveProperty("role");
expect(decodedPayload.role).toEqual("service_role");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload.iss).toEqual("supabase");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.exp).toEqual(expiry);
});
});
});

View File

@@ -25,10 +25,8 @@ const baseApp: ApplicationNested = {
registryUrl: "",
watchPaths: [],
buildArgs: null,
buildSecrets: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewBuildSecrets: null,
triggerType: "push",
previewCertificateType: "none",
previewEnv: null,
@@ -113,7 +111,6 @@ const baseApp: ApplicationNested = {
updateConfigSwarm: null,
username: null,
dockerContextPath: null,
stopGracePeriodSwarm: null,
};
const baseDomain: Domain = {

View File

@@ -25,7 +25,6 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
@@ -177,18 +176,10 @@ const addSwarmSettings = z.object({
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
});
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface Props {
id: string;
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
@@ -233,22 +224,12 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
modeSwarm: null,
labelsSwarm: null,
networkSwarm: null,
stopGracePeriodSwarm: null,
},
resolver: zodResolver(addSwarmSettings),
});
useEffect(() => {
if (data) {
const stopGracePeriodValue = hasStopGracePeriodSwarm(data)
? data.stopGracePeriodSwarm
: null;
const normalizedStopGracePeriod =
stopGracePeriodValue === null || stopGracePeriodValue === undefined
? null
: typeof stopGracePeriodValue === "bigint"
? stopGracePeriodValue
: BigInt(stopGracePeriodValue);
form.reset({
healthCheckSwarm: data.healthCheckSwarm
? JSON.stringify(data.healthCheckSwarm, null, 2)
@@ -274,7 +255,6 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
networkSwarm: data.networkSwarm
? JSON.stringify(data.networkSwarm, null, 2)
: null,
stopGracePeriodSwarm: normalizedStopGracePeriod,
});
}
}, [form, form.reset, data]);
@@ -295,7 +275,6 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
modeSwarm: data.modeSwarm,
labelsSwarm: data.labelsSwarm,
networkSwarm: data.networkSwarm,
stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
})
.then(async () => {
toast.success("Swarm settings updated");
@@ -373,9 +352,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
language="json"
placeholder={`{
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
"Interval" : 10000000000,
"Timeout" : 10000000000,
"StartPeriod" : 10000000000,
"Interval" : 10000,
"Timeout" : 10000,
"StartPeriod" : 10000,
"Retries" : 10
}`}
className="h-[12rem] font-mono"
@@ -428,9 +407,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
language="json"
placeholder={`{
"Condition" : "on-failure",
"Delay" : 10000000000,
"Delay" : 10000,
"MaxAttempts" : 10,
"Window" : 10000000000
"Window" : 10000
} `}
className="h-[12rem] font-mono"
{...field}
@@ -550,9 +529,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
language="json"
placeholder={`{
"Parallelism" : 1,
"Delay" : 10000000000,
"Delay" : 10000,
"FailureAction" : "continue",
"Monitor" : 10000000000,
"Monitor" : 10000,
"MaxFailureRatio" : 10,
"Order" : "start-first"
}`}
@@ -608,9 +587,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
language="json"
placeholder={`{
"Parallelism" : 1,
"Delay" : 10000000000,
"Delay" : 10000,
"FailureAction" : "continue",
"Monitor" : 10000000000,
"Monitor" : 10000,
"MaxFailureRatio" : 10,
"Order" : "start-first"
}`}
@@ -795,57 +774,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="stopGracePeriodSwarm"
render={({ field }) => (
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
Duration in nanoseconds
<HelpCircle className="size-4 text-muted-foreground" />
</FormDescription>
</TooltipTrigger>
<TooltipContent
className="w-full z-[999]"
align="start"
side="bottom"
>
<code>
<pre>
{`Enter duration in nanoseconds:
• 30000000000 - 30 seconds
• 120000000000 - 2 minutes
• 3600000000000 - 1 hour
• 0 - no grace period`}
</pre>
</code>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<FormControl>
<Input
type="number"
placeholder="30000000000"
className="font-mono"
{...field}
value={field?.value?.toString() || ""}
onChange={(e) =>
field.onChange(
e.target.value ? BigInt(e.target.value) : null,
)
}
/>
</FormControl>
<pre>
<FormMessage />
</pre>
</FormItem>
)}
/>
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border">
<Button
isLoading={isLoading}

View File

@@ -150,10 +150,7 @@ export const ShowResources = ({ id, type }: Props) => {
render={({ field }) => {
return (
<FormItem>
<div
className="flex items-center gap-2"
onClick={(e) => e.preventDefault()}
>
<div className="flex items-center gap-2">
<FormLabel>Memory Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
@@ -185,10 +182,7 @@ export const ShowResources = ({ id, type }: Props) => {
name="memoryReservation"
render={({ field }) => (
<FormItem>
<div
className="flex items-center gap-2"
onClick={(e) => e.preventDefault()}
>
<div className="flex items-center gap-2">
<FormLabel>Memory Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
@@ -221,10 +215,7 @@ export const ShowResources = ({ id, type }: Props) => {
render={({ field }) => {
return (
<FormItem>
<div
className="flex items-center gap-2"
onClick={(e) => e.preventDefault()}
>
<div className="flex items-center gap-2">
<FormLabel>CPU Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
@@ -258,10 +249,7 @@ export const ShowResources = ({ id, type }: Props) => {
render={({ field }) => {
return (
<FormItem>
<div
className="flex items-center gap-2"
onClick={(e) => e.preventDefault()}
>
<div className="flex items-center gap-2">
<FormLabel>CPU Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>

View File

@@ -1,8 +1,8 @@
import { zodResolver } from "@hookform/resolvers/zod";
import jsyaml from "js-yaml";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { parse, stringify, YAMLParseError } from "yaml";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
@@ -38,11 +38,11 @@ interface Props {
export const validateAndFormatYAML = (yamlText: string) => {
try {
const obj = parse(yamlText);
const formattedYaml = stringify(obj, { indent: 4 });
const obj = jsyaml.load(yamlText);
const formattedYaml = jsyaml.dump(obj, { indent: 4 });
return { valid: true, formattedYaml, error: null };
} catch (error) {
if (error instanceof YAMLParseError) {
if (error instanceof jsyaml.YAMLException) {
return {
valid: false,
formattedYaml: yamlText,
@@ -89,7 +89,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: (error as string) || "Invalid YAML",
message: error || "Invalid YAML",
});
return;
}

View File

@@ -318,7 +318,7 @@ export const AddVolumes = ({
control={form.control}
name="content"
render={({ field }) => (
<FormItem className="max-w-full max-w-[45rem]">
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
@@ -327,7 +327,7 @@ export const AddVolumes = ({
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono "
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -1,8 +1,6 @@
import copy from "copy-to-clipboard";
import { Check, Copy, Loader2 } from "lucide-react";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
@@ -31,10 +29,9 @@ export const ShowDeployment = ({
const [data, setData] = useState("");
const [showExtraLogs, setShowExtraLogs] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
@@ -109,20 +106,6 @@ export const ShowDeployment = ({
}
}, [filteredLogs, autoScroll]);
const handleCopy = () => {
const logContent = filteredLogs
.map(({ timestamp, message }: LogLine) =>
`${timestamp?.toISOString() || ""} ${message}`.trim(),
)
.join("\n");
const success = copy(logContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const optionalErrors = parseLogs(errorMessage || "");
return (
@@ -145,27 +128,13 @@ export const ShowDeployment = ({
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<span className="flex items-center gap-2">
<span>
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={handleCopy}
disabled={filteredLogs.length === 0}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
{serverId && (
<div className="flex items-center space-x-2">
<Checkbox

View File

@@ -108,21 +108,6 @@ export const ShowEnvironment = ({ id, type }: Props) => {
});
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading]);
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">

View File

@@ -12,7 +12,6 @@ import { api } from "@/utils/api";
const addEnvironmentSchema = z.object({
env: z.string(),
buildArgs: z.string(),
buildSecrets: z.string(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -38,7 +37,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
defaultValues: {
env: "",
buildArgs: "",
buildSecrets: "",
},
resolver: zodResolver(addEnvironmentSchema),
});
@@ -46,18 +44,15 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Watch form values
const currentEnv = form.watch("env");
const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "") ||
currentBuildSecrets !== (data?.buildSecrets || "");
currentBuildArgs !== (data?.buildArgs || "");
useEffect(() => {
if (data) {
form.reset({
env: data.env || "",
buildArgs: data.buildArgs || "",
buildSecrets: data.buildSecrets || "",
});
}
}, [data, form]);
@@ -66,7 +61,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
mutateAsync({
env: formData.env,
buildArgs: formData.buildArgs,
buildSecrets: formData.buildSecrets,
applicationId,
})
.then(async () => {
@@ -82,25 +76,9 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
form.reset({
env: data?.env || "",
buildArgs: data?.buildArgs || "",
buildSecrets: data?.buildSecrets || "",
});
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading]);
return (
<Card className="bg-background px-6 pb-6">
<Form {...form}>
@@ -126,36 +104,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
{data?.buildType === "dockerfile" && (
<Secrets
name="buildArgs"
title="Build-time Arguments"
title="Build-time Variables"
description={
<span>
Arguments are available only at build-time. See
documentation&nbsp;
Available only at build-time. See documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/building/variables/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</span>
}
placeholder="NPM_TOKEN=xyz"
/>
)}
{data?.buildType === "dockerfile" && (
<Secrets
name="buildSecrets"
title="Build-time Secrets"
description={
<span>
Secrets are specially designed for sensitive information and
are only available at build-time. See documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/building/secrets/"
href="https://docs.docker.com/build/guide/build-args/"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -150,7 +150,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules || false,
})
.then(async () => {
toast.success("Service Provider Saved");
toast.success("Service Provided Saved");
await refetch();
})
.catch(() => {

View File

@@ -59,7 +59,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
const router = useRouter();
const { mutateAsync, isLoading } =
api.application.saveGitProvider.useMutation();
api.application.saveGitProdiver.useMutation();
const form = useForm<GitProvider>({
defaultValues: {

View File

@@ -149,7 +149,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provider Saved");
toast.success("Service Provided Saved");
await refetch();
})
.catch(() => {

View File

@@ -167,7 +167,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provider Saved");
toast.success("Service Provided Saved");
await refetch();
})
.catch(() => {

View File

@@ -46,7 +46,6 @@ const schema = z
.object({
env: z.string(),
buildArgs: z.string(),
buildSecrets: z.string(),
wildcardDomain: z.string(),
port: z.number(),
previewLimit: z.number(),
@@ -110,7 +109,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
form.reset({
env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "",
buildSecrets: data.previewBuildSecrets || "",
wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000,
previewLabels: data.previewLabels || [],
@@ -129,7 +127,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
updateApplication({
previewEnv: formData.env,
previewBuildArgs: formData.buildArgs,
previewBuildSecrets: formData.buildSecrets,
previewWildcard: formData.wildcardDomain,
previewPort: formData.port,
previewLabels: formData.previewLabels,
@@ -470,37 +467,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
{data?.buildType === "dockerfile" && (
<Secrets
name="buildArgs"
title="Build-time Arguments"
title="Build-time Variables"
description={
<span>
Arguments are available only at build-time. See
documentation&nbsp;
Available only at build-time. See documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/building/variables/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</span>
}
placeholder="NPM_TOKEN=xyz"
/>
)}
{data?.buildType === "dockerfile" && (
<Secrets
name="buildSecrets"
title="Build-time Secrets"
description={
<span>
Secrets are specially designed for sensitive information
and are only available at build-time. See
documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/building/secrets/"
href="https://docs.docker.com/build/guide/build-args/"
target="_blank"
rel="noopener noreferrer"
>

View File

@@ -7,7 +7,7 @@ import {
RefreshCw,
} from "lucide-react";
import { useEffect, useState } from "react";
import { type Control, useForm } from "react-hook-form";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -57,7 +57,6 @@ export const commonCronExpressions = [
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
{ label: "Every 15 minutes", value: "*/15 * * * *" },
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
{ label: "Custom", value: "custom" },
];
const formSchema = z
@@ -116,91 +115,10 @@ interface Props {
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
}
export const ScheduleFormField = ({
name,
formControl,
}: {
name: string;
formControl: Control<any>;
}) => {
const [selectedOption, setSelectedOption] = useState("");
return (
<FormField
control={formControl}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>Cron expression format: minute hour day month weekday</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
value={selectedOption}
onValueChange={(value) => {
setSelectedOption(value);
field.onChange(value === "custom" ? "" : value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label}
{expr.value !== "custom" && ` (${expr.value})`}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
onChange={(e) => {
const value = e.target.value;
const commonExpression = commonCronExpressions.find(
(expression) => expression.value === value,
);
if (commonExpression) {
setSelectedOption(commonExpression.value);
} else {
setSelectedOption("custom");
}
field.onChange(e);
}}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron expression
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
);
};
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@@ -459,9 +377,63 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
)}
/>
<ScheduleFormField
<FormField
control={form.control}
name="cronExpression"
formControl={form.control}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cron expression format: minute hour day month
weekday
</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label} ({expr.value})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron
expression
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(scheduleTypeForm === "application" ||

View File

@@ -6,7 +6,6 @@ import {
Terminal,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -34,9 +33,6 @@ interface Props {
}
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
const [runningSchedules, setRunningSchedules] = useState<Set<string>>(
new Set(),
);
const {
data: schedules,
isLoading: isLoadingSchedules,
@@ -50,27 +46,14 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
enabled: !!id,
},
);
const utils = api.useUtils();
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
api.schedule.delete.useMutation();
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
const handleRunManually = async (scheduleId: string) => {
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
try {
await runManually({ scheduleId });
toast.success("Schedule run successfully");
await refetchSchedules();
} catch {
toast.error("Error running schedule");
} finally {
setRunningSchedules((prev) => {
const newSet = new Set(prev);
newSet.delete(scheduleId);
return newSet;
});
}
};
const { mutateAsync: runManually, isLoading } =
api.schedule.runManually.useMutation();
return (
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
@@ -84,6 +67,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
@@ -91,7 +75,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
</CardHeader>
<CardContent className="px-0">
{isLoadingSchedules ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
<span className="text-sm text-muted-foreground/70">
Loading scheduled tasks...
@@ -107,13 +91,13 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
return (
<div
key={schedule.scheduleId}
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
className="flex items-center flex-wrap sm:flex-nowrap gap-y-2 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">
<Clock className="size-4 text-primary/70" />
</div>
<div className="space-y-1.5 w-full sm:w-auto">
<div className="space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-sm font-medium leading-none [overflow-wrap:anywhere] line-clamp-3">
{schedule.name}
@@ -148,15 +132,16 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
)}
</div>
{schedule.command && (
<div className="flex items-start gap-2 max-w-full">
<Terminal className="size-3.5 text-muted-foreground/70 flex-shrink-0 mt-0.5" />
<code className="font-mono text-[10px] text-muted-foreground/70 break-all max-w-[calc(100%-20px)]">
<div className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground/70" />
<code className="font-mono text-[10px] text-muted-foreground/70">
{schedule.command}
</code>
</div>
)}
</div>
</div>
<div className="flex items-center gap-0.5 md:gap-1.5">
<ShowDeploymentsModal
id={schedule.scheduleId}
@@ -164,9 +149,10 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
serverId={serverId || undefined}
>
<Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors" />
<ClipboardList className="size-4 transition-colors " />
</Button>
</ShowDeploymentsModal>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
@@ -174,26 +160,37 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
type="button"
variant="ghost"
size="icon"
disabled={runningSchedules.has(schedule.scheduleId)}
onClick={() =>
handleRunManually(schedule.scheduleId)
}
isLoading={isLoading}
onClick={async () => {
toast.success("Schedule run successfully");
await runManually({
scheduleId: schedule.scheduleId,
})
.then(async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1500),
);
refetchSchedules();
})
.catch(() => {
toast.error("Error running schedule");
});
}}
>
{runningSchedules.has(schedule.scheduleId) ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4 transition-colors" />
)}
<Play className="size-4 transition-colors" />
</Button>
</TooltipTrigger>
<TooltipContent>Run Manual Schedule</TooltipContent>
</Tooltip>
</TooltipProvider>
<HandleSchedules
scheduleId={schedule.scheduleId}
id={id}
scheduleType={scheduleType}
/>
<DialogAction
title="Delete Schedule"
description="Are you sure you want to delete this schedule?"
@@ -217,8 +214,8 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
disabled={isDeleting}
className="group hover:bg-red-500/10 "
isLoading={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>

View File

@@ -1,5 +1,11 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
import {
DatabaseZap,
Info,
PenBoxIcon,
PlusCircle,
RefreshCw,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -41,7 +47,7 @@ import {
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import type { CacheType } from "../domains/handle-domain";
import { ScheduleFormField } from "../schedules/handle-schedules";
import { commonCronExpressions } from "../schedules/handle-schedules";
const formSchema = z
.object({
@@ -300,9 +306,64 @@ export const HandleVolumeBackups = ({
</FormItem>
)}
/>
<ScheduleFormField
<FormField
control={form.control}
name="cronExpression"
formControl={form.control}
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cron expression format: minute hour day month
weekday
</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label} ({expr.value})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron
expression
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField

View File

@@ -5,7 +5,6 @@ import {
Play,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -39,7 +38,6 @@ export const ShowVolumeBackups = ({
type = "application",
serverId,
}: Props) => {
const [runningBackups, setRunningBackups] = useState<Set<string>>(new Set());
const {
data: volumeBackups,
isLoading: isLoadingVolumeBackups,
@@ -53,33 +51,19 @@ export const ShowVolumeBackups = ({
enabled: !!id,
},
);
const utils = api.useUtils();
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
api.volumeBackups.delete.useMutation();
const { mutateAsync: runManually } =
api.volumeBackups.runManually.useMutation();
const handleRunManually = async (volumeBackupId: string) => {
setRunningBackups((prev) => new Set(prev).add(volumeBackupId));
try {
await runManually({ volumeBackupId });
toast.success("Volume backup run successfully");
await refetchVolumeBackups();
} catch {
toast.error("Error running volume backup");
} finally {
setRunningBackups((prev) => {
const newSet = new Set(prev);
newSet.delete(volumeBackupId);
return newSet;
});
}
};
const { mutateAsync: runManually, isLoading } =
api.volumeBackups.runManually.useMutation();
return (
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center flex-wrap gap-2">
<div className="flex justify-between items-center">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Volume Backups
@@ -89,10 +73,12 @@ export const ShowVolumeBackups = ({
intervals.
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-2">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups
id={id}
@@ -107,7 +93,7 @@ export const ShowVolumeBackups = ({
</CardHeader>
<CardContent className="px-0">
{isLoadingVolumeBackups ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
<span className="text-sm text-muted-foreground/70">
Loading volume backups...
@@ -127,13 +113,13 @@ export const ShowVolumeBackups = ({
return (
<div
key={volumeBackup.volumeBackupId}
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
className="flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
>
<div className="flex items-start gap-3 w-full sm:w-auto">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
<DatabaseBackup className="size-4 text-primary/70" />
</div>
<div className="space-y-1.5 w-full sm:w-auto">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium leading-none">
{volumeBackup.name}
@@ -157,16 +143,18 @@ export const ShowVolumeBackups = ({
</div>
</div>
</div>
<div className="flex items-center gap-1.5 mt-2 sm:mt-0 sm:ml-3">
<div className="flex items-center gap-1.5">
<ShowDeploymentsModal
id={volumeBackup.volumeBackupId}
type="volumeBackup"
serverId={serverId || undefined}
>
<Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors" />
<ClipboardList className="size-4 transition-colors " />
</Button>
</ShowDeploymentsModal>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
@@ -174,18 +162,25 @@ export const ShowVolumeBackups = ({
type="button"
variant="ghost"
size="icon"
disabled={runningBackups.has(
volumeBackup.volumeBackupId,
)}
onClick={() =>
handleRunManually(volumeBackup.volumeBackupId)
}
isLoading={isLoading}
onClick={async () => {
toast.success("Volume backup run successfully");
await runManually({
volumeBackupId: volumeBackup.volumeBackupId,
})
.then(async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1500),
);
refetchVolumeBackups();
})
.catch(() => {
toast.error("Error running volume backup");
});
}}
>
{runningBackups.has(volumeBackup.volumeBackupId) ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4 transition-colors" />
)}
<Play className="size-4 transition-colors" />
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -193,11 +188,13 @@ export const ShowVolumeBackups = ({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<HandleVolumeBackups
volumeBackupId={volumeBackup.volumeBackupId}
id={id}
volumeBackupType={type}
/>
<DialogAction
title="Delete Volume Backup"
description="Are you sure you want to delete this volume backup?"
@@ -221,7 +218,7 @@ export const ShowVolumeBackups = ({
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
className="group hover:bg-red-500/10 "
isLoading={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
@@ -233,7 +230,7 @@ export const ShowVolumeBackups = ({
})}
</div>
) : (
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
<DatabaseBackup className="size-8 mb-4 text-muted-foreground" />
<p className="text-lg font-medium text-muted-foreground">
No volume backups

View File

@@ -104,7 +104,7 @@ export const DeleteService = ({ id, type }: Props) => {
push(
`/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`,
);
toast.success("Service deleted successfully");
toast.success("deleted successfully");
setIsOpen(false);
})
.catch(() => {

View File

@@ -195,7 +195,6 @@ export const ComposeActions = ({ composeId }: Props) => {
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"}
>
<Button
variant="outline"

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -35,7 +35,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
);
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const form = useForm<AddComposeFile>({
defaultValues: {
@@ -54,12 +53,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
}
}, [form, form.reset, data]);
useEffect(() => {
if (data?.composeFile !== undefined) {
setHasUnsavedChanges(composeFile !== data.composeFile);
}
}, [composeFile, data?.composeFile]);
const onSubmit = async (data: AddComposeFile) => {
const { valid, error } = validateAndFormatYAML(data.composeFile);
if (!valid) {
@@ -74,12 +67,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
await mutateAsync({
composeId,
composeFile: data.composeFile,
composePath: "./docker-compose.yml",
sourceType: "raw",
})
.then(async () => {
toast.success("Compose config Updated");
setHasUnsavedChanges(false);
refetch();
await utils.compose.getConvertedCompose.invalidate({
composeId,
@@ -108,19 +99,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
return (
<>
<div className="w-full flex flex-col gap-4 ">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium">Compose File</h3>
<p className="text-sm text-muted-foreground">
Configure your Docker Compose file for this service.
{hasUnsavedChanges && (
<span className="text-yellow-500 ml-2">
(You have unsaved changes)
</span>
)}
</p>
</div>
</div>
<Form {...form}>
<form
id="hook-form-save-compose-file"

View File

@@ -152,7 +152,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provider Saved");
toast.success("Service Provided Saved");
await refetch();
})
.catch(() => {

View File

@@ -151,7 +151,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
triggerType: data.triggerType,
})
.then(async () => {
toast.success("Service Provider Saved");
toast.success("Service Provided Saved");
await refetch();
})
.catch(() => {

View File

@@ -160,7 +160,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
toast.success("Service Provider Saved");
toast.success("Service Provided Saved");
await refetch();
})
.catch(() => {

View File

@@ -37,6 +37,8 @@ interface Props {
serverId?: string;
}
badgeStateColor;
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();

View File

@@ -3,6 +3,7 @@ import {
CheckIcon,
ChevronsUpDown,
DatabaseZap,
Info,
PenBoxIcon,
PlusIcon,
RefreshCw,
@@ -61,7 +62,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { ScheduleFormField } from "../../application/schedules/handle-schedules";
import { commonCronExpressions } from "../../application/schedules/handle-schedules";
type CacheType = "cache" | "fetch";
@@ -578,9 +579,66 @@ export const HandleBackup = ({
);
}}
/>
<ScheduleFormField name="schedule" formControl={form.control} />
<FormField
control={form.control}
name="schedule"
render={({ field }) => {
return (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cron expression format: minute hour day month
weekday
</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label} ({expr.value})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron
expression
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="prefix"

View File

@@ -1,12 +1,4 @@
import copy from "copy-to-clipboard";
import {
Check,
Copy,
Download as DownloadIcon,
Loader2,
Pause,
Play,
} from "lucide-react";
import { Download as DownloadIcon, Loader2, Pause, Play } from "lucide-react";
import React, { useEffect, useRef } from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
@@ -75,7 +67,6 @@ export const DockerLogsId: React.FC<Props> = ({
const isPausedRef = useRef(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
@@ -246,29 +237,6 @@ export const DockerLogsId: React.FC<Props> = ({
URL.revokeObjectURL(url);
};
const handleCopy = async () => {
const logContent = filteredLogs
.map(
({
timestamp,
message,
}: {
timestamp: Date | null;
message: string;
}) =>
showTimestamp
? `${timestamp?.toISOString() || "No timestamp"} ${message}`
: message,
)
.join("\n");
const success = copy(logContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleFilter = (logs: LogLine[]) => {
return logs.filter((log) => {
const logType = getLogType(log.message).type;
@@ -352,21 +320,6 @@ export const DockerLogsId: React.FC<Props> = ({
)}
{isPaused ? "Resume" : "Pause"}
</Button>
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleCopy}
disabled={filteredLogs.length === 0}
title="Copy logs to clipboard"
>
{copied ? (
<Check className="mr-2 h-4 w-4" />
) : (
<Copy className="mr-2 h-4 w-4" />
)}
Copy
</Button>
<Button
variant="outline"
size="sm"

View File

@@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface Props {
id: string;
containerId?: string;
containerId: string;
serverId?: string;
}
@@ -36,6 +36,7 @@ export const DockerTerminal: React.FC<Props> = ({
},
});
const addonFit = new FitAddon();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
@@ -56,7 +57,7 @@ export const DockerTerminal: React.FC<Props> = ({
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 mt-4">
<div className="flex flex-col gap-2">
<span>
Select way to connect to <b>{containerId}</b>
</span>

View File

@@ -281,7 +281,6 @@ export const ImpersonationBar = () => {
<div className="flex items-center gap-4 flex-1 flex-wrap">
<Avatar className="h-10 w-10">
<AvatarImage
className="object-cover"
src={data?.user?.image || ""}
alt={data?.user?.name || ""}
/>

View File

@@ -55,7 +55,7 @@ import { api } from "@/utils/api";
type DbType = typeof mySchema._type.type;
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mongo: "mongo:7",
mongo: "mongo:6",
mariadb: "mariadb:11",
mysql: "mysql:8",
postgres: "postgres:15",

View File

@@ -171,7 +171,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
<Input
placeholder="Search Template"
onChange={(e) => setQuery(e.target.value)}
className="w-full"
className="w-full sm:w-[200px]"
value={query}
/>
<Input
@@ -248,7 +248,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
onClick={() =>
setViewMode(viewMode === "detailed" ? "icon" : "detailed")
}
className="h-9 w-9 flex-shrink-0"
className="h-9 w-9"
>
{viewMode === "detailed" ? (
<LayoutGrid className="size-4" />

View File

@@ -63,20 +63,13 @@ export const AdvancedEnvironmentSelector = ({
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;
// API mutations
const { data: environment } = api.environment.one.useQuery(
{ environmentId: currentEnvironmentId || "" },
{
enabled: !!currentEnvironmentId,
},
);
const haveServices =
selectedEnvironment &&
@@ -248,7 +241,7 @@ export const AdvancedEnvironmentSelector = ({
</DropdownMenuItem>
{/* Action buttons for non-production environments */}
{/* <EnvironmentVariables environmentId={environment.environmentId}>
<EnvironmentVariables environmentId={environment.environmentId}>
<Button
variant="ghost"
size="sm"
@@ -259,7 +252,7 @@ export const AdvancedEnvironmentSelector = ({
>
<Terminal className="h-3 w-3" />
</Button>
</EnvironmentVariables> */}
</EnvironmentVariables>
{environment.name !== "production" && (
<div className="flex items-center gap-1 px-2">
<Button
@@ -274,19 +267,17 @@ export const AdvancedEnvironmentSelector = ({
<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>
)}
<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>
@@ -294,15 +285,13 @@ export const AdvancedEnvironmentSelector = ({
})}
<DropdownMenuSeparator />
{canCreateEnvironments && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setIsCreateDialogOpen(true)}
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Environment
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer"
onClick={() => setIsCreateDialogOpen(true)}
>
<PlusIcon className="h-4 w-4 mr-2" />
Create Environment
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -88,7 +88,7 @@ export const StepThree = ({ templateInfo }: StepProps) => {
<div>
<h3 className="text-sm font-semibold">Configuration Files</h3>
<ul className="list-disc pl-5">
{templateInfo?.details?.configFiles?.map((file, index) => (
{templateInfo?.details?.configFiles.map((file, index) => (
<li key={index}>
<strong className="text-sm font-semibold">
{file.filePath}

View File

@@ -1,5 +1,5 @@
import { Bot, PlusCircle, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -27,6 +27,7 @@ export interface StepProps {
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
const suggestions = templateInfo.suggestions || [];
const selectedVariant = templateInfo.details;
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
const { mutateAsync, isLoading, error, isError } =
api.ai.suggest.useMutation();
@@ -43,7 +44,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
.then((data) => {
setTemplateInfo({
...templateInfo,
suggestions: data || [],
suggestions: data,
});
})
.catch((error) => {
@@ -53,6 +54,10 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
});
}, [templateInfo.userInput]);
const toggleShowValue = (name: string) => {
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
};
const handleEnvVariableChange = (
index: number,
field: "name" | "value",
@@ -303,9 +308,11 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
placeholder="Variable Name"
className="flex-1"
/>
<div className="relative">
<div className="flex-1 relative">
<Input
type={"password"}
type={
showValues[env.name] ? "text" : "password"
}
value={env.value}
onChange={(e) =>
handleEnvVariableChange(
@@ -316,6 +323,19 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
}
placeholder="Variable Value"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 transform -translate-y-1/2"
onClick={() => toggleShowValue(env.name)}
>
{showValues[env.name] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<Button
type="button"
@@ -417,14 +437,13 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
<AccordionContent>
<ScrollArea className="w-full rounded-md border">
<div className="p-4 space-y-4">
{selectedVariant?.configFiles?.length &&
selectedVariant?.configFiles?.length > 0 ? (
{selectedVariant?.configFiles?.length > 0 ? (
<>
<div className="text-sm text-muted-foreground mb-4">
This template requires the following
configuration files to be mounted:
</div>
{selectedVariant?.configFiles?.map(
{selectedVariant.configFiles.map(
(config, index) => (
<div
key={index}

View File

@@ -47,7 +47,7 @@ interface Details {
envVariables: EnvVariable[];
shortDescription: string;
domains: Domain[];
configFiles?: Mount[];
configFiles: Mount[];
}
interface Mount {

View File

@@ -80,29 +80,6 @@ export const DuplicateProject = ({
api.project.duplicate.useMutation({
onSuccess: async (newProject) => {
await utils.project.all.invalidate();
// If duplicating to same project+environment, invalidate the environment query
// to refresh the services list
if (duplicateType === "existing-environment") {
await utils.environment.one.invalidate({
environmentId: selectedTargetEnvironment,
});
await utils.environment.byProjectId.invalidate({
projectId: selectedTargetProject,
});
// If duplicating to the same environment we're currently viewing,
// also invalidate the current environment to refresh the services list
if (selectedTargetEnvironment === environmentId) {
await utils.environment.one.invalidate({ environmentId });
// Also invalidate the project query to refresh the project data
const projectId = router.query.projectId as string;
if (projectId) {
await utils.project.one.invalidate({ projectId });
}
}
}
toast.success(
duplicateType === "new-project"
? "Project duplicated successfully"

View File

@@ -82,21 +82,6 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
.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>

View File

@@ -81,21 +81,6 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
.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>

View File

@@ -96,30 +96,8 @@ export const ShowProjects = () => {
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
case "services": {
const aTotalServices = a.environments.reduce((total, env) => {
return (
total +
(env.applications?.length || 0) +
(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);
const bTotalServices = b.environments.reduce((total, env) => {
return (
total +
(env.applications?.length || 0) +
(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);
const aTotalServices = a.environments.length;
const bTotalServices = b.environments.length;
comparison = aTotalServices - bTotalServices;
break;
}
@@ -313,48 +291,45 @@ export const ShowProjects = () => {
)}
</DropdownMenuGroup>
)}
{project.environments.some(
(env) => env.compose.length > 0,
) && (
{/*
{project.compose.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Compose
</DropdownMenuLabel>
{project.environments.map((env) =>
env.compose.map((comp) => (
<div key={comp.composeId}>
{project.compose.map((comp) => (
<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 />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
)),
)}
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
))}
</DropdownMenuGroup>
)}
)} */}
</DropdownMenuContent>
</DropdownMenu>
) : null}

View File

@@ -24,6 +24,7 @@ import { Progress } from "@/components/ui/progress";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { ShowProviders } from "./show-providers";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
@@ -151,44 +152,25 @@ export const ShowBilling = () => {
<Loader2 className="animate-spin" />
</span>
) : (
<>
{products?.map((product) => {
const featured = true;
return (
<div key={product.id}>
<section
className={clsx(
"flex flex-col rounded-3xl border-dashed border-2 px-4 max-w-sm",
featured
? "order-first border py-8 lg:order-none"
: "lg:py-8",
)}
>
{isAnnual && (
<div className="mb-4 flex flex-row items-center gap-2">
<Badge>Recommended 🚀</Badge>
</div>
)}
{isAnnual ? (
<div className="flex flex-row gap-2 items-center">
<p className="text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(
serverQuantity,
isAnnual,
).toFixed(2)}{" "}
USD
</p>
|
<p className="text-base font-semibold tracking-tight text-muted-foreground">
${" "}
{(
calculatePrice(serverQuantity, isAnnual) / 12
).toFixed(2)}{" "}
/ Month USD
</p>
</div>
) : (
products?.map((product) => {
const featured = true;
return (
<div key={product.id}>
<section
className={clsx(
"flex flex-col rounded-3xl border-dashed border-2 px-4 max-w-sm",
featured
? "order-first border py-8 lg:order-none"
: "lg:py-8",
)}
>
{isAnnual && (
<div className="mb-4 flex flex-row items-center gap-2">
<Badge>Recommended 🚀</Badge>
</div>
)}
{isAnnual ? (
<div className="flex flex-row gap-2 items-center">
<p className="text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(serverQuantity, isAnnual).toFixed(
@@ -196,127 +178,146 @@ export const ShowBilling = () => {
)}{" "}
USD
</p>
)}
<h3 className="mt-5 font-medium text-lg text-primary">
{product.name}
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
{product.description}
|
<p className="text-base font-semibold tracking-tight text-muted-foreground">
${" "}
{(
calculatePrice(serverQuantity, isAnnual) / 12
).toFixed(2)}{" "}
/ Month USD
</p>
</div>
) : (
<p className="text-2xl font-semibold tracking-tight text-primary ">
${" "}
{calculatePrice(serverQuantity, isAnnual).toFixed(
2,
)}{" "}
USD
</p>
)}
<h3 className="mt-5 font-medium text-lg text-primary">
{product.name}
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
{product.description}
</p>
<ul
className={clsx(
" mt-4 flex flex-col gap-y-2 text-sm",
featured ? "text-white" : "text-slate-200",
<ul
className={clsx(
" mt-4 flex flex-col gap-y-2 text-sm",
featured ? "text-white" : "text-slate-200",
)}
>
{[
"All the features of Dokploy",
"Unlimited deployments",
"Self-hosted on your own infrastructure",
"Full access to all deployment features",
"Dokploy integration",
"Backups",
"All Incoming features",
].map((feature) => (
<li
key={feature}
className="flex text-muted-foreground"
>
<CheckIcon />
<span className="ml-4">{feature}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-2 mt-4">
<div className="flex items-center gap-2 justify-center">
<span className="text-sm text-muted-foreground">
{serverQuantity} Servers
</span>
</div>
<div className="flex items-center space-x-2">
<Button
disabled={serverQuantity <= 1}
variant="outline"
onClick={() => {
if (serverQuantity <= 1) return;
setServerQuantity(serverQuantity - 1);
}}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
onChange={(e) => {
setServerQuantity(
e.target.value as unknown as number,
);
}}
/>
<Button
variant="outline"
onClick={() => {
setServerQuantity(serverQuantity + 1);
}}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div
className={cn(
data?.subscriptions &&
data?.subscriptions?.length > 0
? "justify-between"
: "justify-end",
"flex flex-row items-center gap-2 mt-4",
)}
>
{[
"All the features of Dokploy",
"Unlimited deployments",
"Self-hosted on your own infrastructure",
"Full access to all deployment features",
"Dokploy integration",
"Backups",
"All Incoming features",
].map((feature) => (
<li
key={feature}
className="flex text-muted-foreground"
>
<CheckIcon />
<span className="ml-4">{feature}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-2 mt-4">
<div className="flex items-center gap-2 justify-center">
<span className="text-sm text-muted-foreground">
{serverQuantity} Servers
</span>
</div>
<div className="flex items-center space-x-2">
{admin?.user.stripeCustomerId && (
<Button
disabled={serverQuantity <= 1}
variant="outline"
onClick={() => {
if (serverQuantity <= 1) return;
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
setServerQuantity(serverQuantity - 1);
window.open(session.url);
}}
>
<MinusIcon className="h-4 w-4" />
Manage Subscription
</Button>
<NumberInput
value={serverQuantity}
onChange={(e) => {
setServerQuantity(
e.target.value as unknown as number,
);
}}
/>
)}
<Button
variant="outline"
onClick={() => {
setServerQuantity(serverQuantity + 1);
}}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div
className={cn(
data?.subscriptions &&
data?.subscriptions?.length > 0
? "justify-between"
: "justify-end",
"flex flex-row items-center gap-2 mt-4",
)}
>
{admin?.user.stripeCustomerId && (
{data?.subscriptions?.length === 0 && (
<div className="justify-end w-full">
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session =
await createCustomerPortalSession();
window.open(session.url);
handleCheckout(product.id);
}}
disabled={serverQuantity < 1}
>
Manage Subscription
Subscribe
</Button>
)}
{data?.subscriptions?.length === 0 && (
<div className="justify-end w-full">
<Button
className="w-full"
onClick={async () => {
handleCheckout(product.id);
}}
disabled={serverQuantity < 1}
>
Subscribe
</Button>
</div>
)}
</div>
</div>
)}
</div>
</section>
</div>
);
})}
</>
</div>
</section>
</div>
);
})
)}
</div>
</CardContent>
<div className="flex flex-col gap-4 pb-10">
<ShowProviders />
</div>
</div>
</Card>
</div>

View File

@@ -0,0 +1,355 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
Cpu,
EuroIcon,
HardDrive,
Loader2,
MapPin,
MemoryStick,
Zap,
} from "lucide-react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
// Function to classify servers by type
function getServerCategory(cpuType: string) {
if (cpuType === "shared") {
return {
category: "Shared CPU",
icon: Cpu,
badge: "shared",
color: "bg-blue-500",
description: "Perfect for small and medium projects",
};
}
return {
category: "Dedicated CPU",
icon: Zap,
badge: "dedicated",
color: "bg-purple-500",
description: "Maximum performance for demanding applications",
};
}
const formSchema = z.object({
location: z.string().min(1, "Please select a location"),
architecture: z.enum(["x86", "arm"], {
required_error: "Please select an architecture",
}),
selectedServerId: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
export const ShowHetznerProviders = () => {
const { data: serverTypesData, isLoading: isLoadingTypes } =
api.hetzner.serverTypes.useQuery();
const { data: locationsData, isLoading: isLoadingLocations } =
api.hetzner.locations.useQuery();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
location: "",
architecture: "x86",
selectedServerId: "",
},
});
const selectedLocation = form.watch("location");
const selectedArchitecture = form.watch("architecture");
if (isLoadingTypes || isLoadingLocations) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
const locations = locationsData?.locations ?? [];
const serverTypes = serverTypesData?.server_types ?? [];
// Filter server types by selected location AND architecture
const filteredServerTypes = serverTypes.filter(
(type) =>
type.prices.some((price) => price.location === selectedLocation) &&
type.architecture === selectedArchitecture,
);
// Group by CPU type (shared/dedicated)
const sharedServers = filteredServerTypes.filter(
(type) => type.cpu_type === "shared",
);
const dedicatedServers = filteredServerTypes.filter(
(type) => type.cpu_type === "dedicated",
);
const renderServerGrid = (
servers: typeof serverTypes,
category: ReturnType<typeof getServerCategory>,
) => {
if (!servers.length) return null;
const IconComponent = category.icon;
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<IconComponent className="h-5 w-5" />
<h3 className="text-lg font-semibold">{category.category}</h3>
<Badge variant="outline" className={`text-white ${category.color}`}>
{category.badge}
</Badge>
<Badge variant="outline" className="text-xs text-muted-foreground">
Sorted by price
</Badge>
</div>
<p className="text-sm text-muted-foreground mb-4">
{category.description}
</p>
<FormField
control={form.control}
name="selectedServerId"
render={({ field }) => (
<FormItem className="space-y-0">
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
{servers.map((server) => (
<div key={server.id} className="relative">
<RadioGroupItem
value={server.id.toString()}
id={`server-${server.id}`}
className="absolute right-4 top-4 z-10"
/>
<label htmlFor={`server-${server.id}`}>
<Card
className={`relative bg-transparent transition-all duration-200 cursor-pointer ${
field.value === server.id.toString()
? "border-primary bg-primary/5"
: "hover:bg-primary/5"
}`}
>
<CardHeader>
<CardTitle>{server.name}</CardTitle>
<CardDescription>
{server.description}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-blue-500" />
<div>
<strong>Cores:</strong> {server.cores}
</div>
</div>
<div className="flex items-center gap-2">
<MemoryStick className="h-4 w-4 text-green-500" />
<div>
<strong>Memory:</strong> {server.memory} GB
</div>
</div>
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-purple-500" />
<div>
<strong>Disk:</strong> {server.disk} GB
</div>
</div>
{/* Show price for selected location */}
{server.prices
.filter((p) => p.location === selectedLocation)
.map((p) => (
<div
key={p.location}
className="flex items-center gap-2"
>
<EuroIcon className="h-4 w-4 text-yellow-500" />
<div>
<strong>Price (monthly):</strong>
{Number.parseFloat(
p.price_monthly.net,
).toFixed(2)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</label>
</div>
))}
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
);
};
function onSubmit(values: FormValues) {
console.log("Form submitted:", values);
// Here you can handle the form submission with the selected server
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Filters Card */}
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<MapPin className="h-5 w-5" />
Filters
</CardTitle>
<CardDescription>
Choose a region and architecture to see location-specific pricing
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Form {...form}>
<div className="space-y-6">
{/* Region Selector */}
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Region</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="Select a region" />
</SelectTrigger>
<SelectContent>
{locations.map((loc) => (
<SelectItem key={loc.id} value={loc.name}>
{loc.description}
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>
{/* Architecture Selector */}
<FormField
control={form.control}
name="architecture"
render={({ field }) => (
<FormItem>
<FormLabel>Architecture</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid grid-cols-2 gap-4"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="x86" />
</FormControl>
<FormLabel className="font-normal">
x86 (Intel/AMD)
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="arm" />
</FormControl>
<FormLabel className="font-normal">ARM</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
</Form>
</CardContent>
</Card>
{/* Architecture Information */}
<div className="grid md:grid-cols-2 gap-4">
<div className="p-4 bg-blue-900 rounded-lg border border-blue-600">
<div className="flex items-start gap-2">
<Cpu className="h-5 w-5 text-blue-600 mt-0.5" />
<div className="text-sm text-blue-200">
<strong>x86 Architecture:</strong> Traditional Intel/AMD
processors. Most compatible with existing software and
applications. Best choice for general-purpose workloads.
</div>
</div>
</div>
<div className="p-4 bg-green-900 rounded-lg border border-green-600">
<div className="flex items-start gap-2">
<Cpu className="h-5 w-5 text-green-600 mt-0.5" />
<div className="text-sm text-green-200">
<strong>ARM Architecture:</strong> Modern, energy-efficient
processors. Excellent price-to-performance ratio. Perfect for
cloud-native and containerized applications.
</div>
</div>
</div>
</div>
{/* Server Types Grid */}
{selectedLocation && (
<>
{renderServerGrid(sharedServers, getServerCategory("shared"))}
{renderServerGrid(dedicatedServers, getServerCategory("dedicated"))}
{sharedServers.length === 0 && dedicatedServers.length === 0 && (
<p className="text-center text-muted-foreground py-10">
No server types available for this region and architecture
combination. <br />
Please try a different region or architecture.
</p>
)}
</>
)}
{selectedLocation && form.watch("selectedServerId") && (
<div className="flex justify-end">
<Button type="submit" className="bg-primary">
Continue with Selected Server
</Button>
</div>
)}
</form>
</Form>
);
};

View File

@@ -0,0 +1,423 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
Calendar,
Cpu,
DollarSign,
Globe,
HardDrive,
Loader2,
MemoryStick,
Server,
} from "lucide-react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { api } from "@/utils/api";
const formSchema = z.object({
datacenter: z.string({
required_error: "Please select a datacenter",
}),
plan: z.string({
required_error: "Please select a server plan",
}),
billingPeriod: z.object(
{
unit: z.enum(["month", "year"]),
period: z.number(),
},
{
required_error: "Please select a billing period",
},
),
});
// Format billing period
function formatBillingPeriod(period: number, unit: string): string {
if (unit === "month") {
return period === 1 ? "Monthly" : `${period} months`;
}
if (unit === "year") {
return period === 1 ? "Yearly" : `${period} years`;
}
return `${period} ${unit}`;
}
// Convert price from cents to dollars
function formatPrice(priceInCents: number): string {
return (priceInCents / 100).toFixed(2);
}
// Calculate yearly savings
function calculateSavings(
monthlyPriceInCents: number,
yearlyPriceInCents: number,
): number {
return (monthlyPriceInCents * 12 - yearlyPriceInCents) / 100;
}
type FormData = z.infer<typeof formSchema>;
export const ShowHostingerServers = () => {
const { data: vpsPlans, isLoading: plansLoading } =
api.hostinger.vpsPlans.useQuery();
const { data: dataCenters, isLoading: centersLoading } =
api.hostinger.dataCenters.useQuery();
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
datacenter: "",
plan: "",
billingPeriod: {
unit: "month",
period: 1,
},
},
});
const isLoading = plansLoading || centersLoading;
function onSubmit(data: FormData) {
console.log(data);
// Handle form submission here
}
if (isLoading) {
return (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
);
}
return (
<div className="space-y-4">
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Hostinger VPS Plans
<Badge variant="outline" className="text-xs text-muted-foreground">
Sorted by price
</Badge>
</CardTitle>
<CardDescription>
VPS plans with real pricing from Hostinger API
<br />
<span className="text-xs text-orange-600">
💡 Promotional pricing applies to first billing period only
</span>
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* Data Center Selection */}
<FormField
control={form.control}
name="datacenter"
render={({ field }) => (
<FormItem className="space-y-4">
<FormLabel>
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-muted-foreground" />
<span className="text-lg font-medium">
Select Data Center
</span>
</div>
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
>
{dataCenters?.map((center) => (
<FormItem key={center.id}>
<FormControl>
<RadioGroupItem
value={center.id?.toString() || ""}
className="peer sr-only"
/>
</FormControl>
<FormLabel className="p-4 rounded-lg border-2 transition-all duration-200 flex flex-col items-center gap-2 peer-aria-checked:border-purple-500 peer-aria-checked:bg-purple-50 dark:peer-aria-checked:bg-purple-950 hover:border-purple-300 cursor-pointer">
<Globe className="h-6 w-6 text-muted-foreground" />
<span className="text-sm font-medium text-center">
{center.city} / {center.continent}
</span>
</FormLabel>
</FormItem>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Billing Period Selection */}
<FormField
control={form.control}
name="billingPeriod"
render={({ field }) => (
<FormItem className="space-y-4">
<FormLabel>
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-muted-foreground" />
<span className="text-lg font-medium">
Billing Period
</span>
</div>
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(value) => {
switch (value) {
case "monthly":
field.onChange({ unit: "month", period: 1 });
break;
case "yearly":
field.onChange({ unit: "year", period: 1 });
break;
case "2years":
field.onChange({ unit: "year", period: 2 });
break;
}
}}
defaultValue="monthly"
className="grid w-full grid-cols-3 lg:w-[600px] gap-4"
>
<FormItem>
<FormControl>
<RadioGroupItem
value="monthly"
className="peer sr-only"
/>
</FormControl>
<FormLabel className="flex items-center justify-center p-3 rounded-lg border-2 transition-all duration-200 peer-aria-checked:border-purple-500 peer-aria-checked:bg-purple-50 dark:peer-aria-checked:bg-purple-950 hover:border-purple-300 cursor-pointer">
Monthly Billing
</FormLabel>
</FormItem>
<FormItem>
<FormControl>
<RadioGroupItem
value="yearly"
className="peer sr-only"
/>
</FormControl>
<FormLabel className="flex items-center justify-center p-3 rounded-lg border-2 transition-all duration-200 peer-aria-checked:border-purple-500 peer-aria-checked:bg-purple-50 dark:peer-aria-checked:bg-purple-950 hover:border-purple-300 cursor-pointer">
Annual Billing
</FormLabel>
</FormItem>
<FormItem>
<FormControl>
<RadioGroupItem
value="2years"
className="peer sr-only"
/>
</FormControl>
<FormLabel className="flex items-center justify-center p-3 rounded-lg border-2 transition-all duration-200 peer-aria-checked:border-purple-500 peer-aria-checked:bg-purple-50 dark:peer-aria-checked:bg-purple-950 hover:border-purple-300 cursor-pointer">
2 Year Billing
</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* VPS Plans Selection */}
<FormField
control={form.control}
name="plan"
render={({ field }) => (
<FormItem className="space-y-4">
<FormLabel>
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-muted-foreground" />
<span className="text-lg font-medium">
Select Server Plan
</span>
</div>
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid grid-cols-1 lg:grid-cols-2 gap-6"
>
{vpsPlans
?.sort((a, b) => {
const billingPeriod = form.watch("billingPeriod");
const priceA =
a.prices?.find(
(p) =>
p.period_unit === billingPeriod.unit &&
p.period === billingPeriod.period,
)?.price || 0;
const priceB =
b.prices?.find(
(p) =>
p.period_unit === billingPeriod.unit &&
p.period === billingPeriod.period,
)?.price || 0;
return priceA - priceB;
})
?.map((plan) => {
const monthlyPrice =
plan.prices?.find(
(p) =>
p.period === 1 && p.period_unit === "month",
)?.price || 0;
const selectedPrice = plan.prices?.find(
(p) =>
p.period_unit ===
form.watch("billingPeriod.unit") &&
p.period === form.watch("billingPeriod.period"),
);
if (!selectedPrice) return null;
return (
<FormItem key={plan.id}>
<FormControl>
<RadioGroupItem
value={plan.id || ""}
className="peer sr-only"
/>
</FormControl>
<FormLabel className="w-full cursor-pointer">
<Card
className={`border-2 transition-all duration-200 relative bg-transparent hover:border-purple-300 hover:shadow-lg ${field.value === plan.id ? "border-purple-500 bg-purple-950/40" : ""}`}
>
{plan.name === "KVM 2" && (
<div className="absolute -top-2 -right-2 bg-green-500 text-white px-2 py-1 rounded-full text-xs font-semibold">
MOST POPULAR
</div>
)}
<CardHeader>
<CardTitle>{plan.name}</CardTitle>
<CardDescription>
{"High-performance VPS hosting"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col items-center p-2 bg-muted rounded-lg">
<Cpu className="h-4 w-4 mb-1 text-muted-foreground" />
<span className="text-sm font-medium">
{plan.metadata?.cpus || 1} vCPU
</span>
<span className="text-xs text-muted-foreground">
Cores
</span>
</div>
<div className="flex flex-col items-center p-2 bg-muted rounded-lg">
<MemoryStick className="h-4 w-4 mb-1 text-muted-foreground" />
<span className="text-sm font-medium">
{Number.parseInt(
plan.metadata?.memory || "2048",
) / 1024}{" "}
GB
</span>
<span className="text-xs text-muted-foreground">
RAM
</span>
</div>
<div className="flex flex-col items-center p-2 bg-muted rounded-lg">
<HardDrive className="h-4 w-4 mb-1 text-muted-foreground" />
<span className="text-sm font-medium">
{Number.parseInt(
plan.metadata?.disk_space ||
"20480",
) / 1024}{" "}
GB
</span>
<span className="text-xs text-muted-foreground">
SSD
</span>
</div>
</div>
<div className="mt-2">
<div className="flex items-center justify-between p-3 bg-primary/5 rounded-lg">
<div className="flex items-center">
<Calendar className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-sm">
{formatBillingPeriod(
selectedPrice.period || 1,
selectedPrice.period_unit ||
"month",
)}
</span>
</div>
<div className="flex items-center">
<DollarSign className="h-4 w-4 mr-1 text-primary" />
<span className="text-lg font-semibold">
$
{formatPrice(
selectedPrice.price || 0,
)}
</span>
{selectedPrice.period_unit ===
"year" && (
<Badge
variant="outline"
className="ml-2 text-xs"
>
Save $
{calculateSavings(
monthlyPrice,
selectedPrice.price || 0,
).toFixed(2)}
/yr
</Badge>
)}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</FormLabel>
</FormItem>
);
})}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Create Server
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,47 @@
import { DollarSign } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ShowHetznerProviders } from "./show-hetzner-providers";
import { ShowHostingerServers } from "./show-hostinger-servers";
export const ShowProviders = () => {
return (
<Card className="w-full bg-transparent border-none">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="h-6 w-6 text-green-600" />
Servers
</CardTitle>
<CardDescription>
Manage and view available server types from Hetzner and Hostinger for
your business. Here you can see updated pricing and specifications for
each plan.
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="hetzner" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="hetzner" className="flex items-center gap-2">
🇩🇪 Hetzner Cloud
</TabsTrigger>
<TabsTrigger value="hostinger" className="flex items-center gap-2">
🌍 Hostinger VPS
</TabsTrigger>
</TabsList>
<TabsContent value="hetzner" className="mt-4">
<ShowHetznerProviders />
</TabsContent>
<TabsContent value="hostinger" className="mt-4">
<ShowHostingerServers />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};

View File

@@ -217,7 +217,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
</DialogDescription>
</DialogHeader>
{(isError || isErrorConnection) && (
<AlertBlock type="error" className="w-full">
<AlertBlock type="error" className="break-words">
{connectionError?.message || error?.message}
</AlertBlock>
)}

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -26,12 +27,18 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const Schema = z.object({
name: z.string().min(1, { message: "Name is required" }),
username: z.string().min(1, { message: "Username is required" }),
email: z.string().email().optional(),
apiToken: z.string().min(1, { message: "API Token is required" }),
name: z.string().min(1, {
message: "Name 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(),
});
@@ -40,12 +47,14 @@ type Schema = z.infer<typeof Schema>;
export const AddBitbucketProvider = () => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const _url = useUrl();
const { mutateAsync, error, isError } = api.bitbucket.create.useMutation();
const { data: auth } = api.user.get.useQuery();
const _router = useRouter();
const form = useForm<Schema>({
defaultValues: {
username: "",
apiToken: "",
password: "",
workspaceName: "",
},
resolver: zodResolver(Schema),
@@ -54,8 +63,7 @@ export const AddBitbucketProvider = () => {
useEffect(() => {
form.reset({
username: "",
email: "",
apiToken: "",
password: "",
workspaceName: "",
});
}, [form, isOpen]);
@@ -63,11 +71,10 @@ export const AddBitbucketProvider = () => {
const onSubmit = async (data: Schema) => {
await mutateAsync({
bitbucketUsername: data.username,
apiToken: data.apiToken,
appPassword: data.password,
bitbucketWorkspaceName: data.workspaceName || "",
authId: auth?.id || "",
name: data.name || "",
bitbucketEmail: data.email || "",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -106,46 +113,37 @@ export const AddBitbucketProvider = () => {
>
<CardContent className="p-0">
<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">
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>
<ul className="list-disc list-inside ml-4 text-sm text-muted-foreground">
<li>read:repository:bitbucket</li>
<li>read:pullrequest:bitbucket</li>
<li>read:webhook:bitbucket</li>
<li>read:workspace:bitbucket</li>
<li>write:webhook:bitbucket</li>
</ul>
<ol className="list-decimal list-inside text-sm text-muted-foreground">
<li className="flex flex-row gap-2 items-center">
Create new App Password{" "}
<Link
href="https://bitbucket.org/account/settings/app-passwords/new"
target="_blank"
>
<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
control={form.control}
name="name"
@@ -154,7 +152,7 @@ export const AddBitbucketProvider = () => {
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Your Bitbucket Provider, eg: my-personal-account"
placeholder="Random Name eg(my-personal-account)"
{...field}
/>
</FormControl>
@@ -181,27 +179,14 @@ export const AddBitbucketProvider = () => {
<FormField
control={form.control}
name="email"
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Bitbucket Email</FormLabel>
<FormControl>
<Input placeholder="Your Bitbucket email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormLabel>App Password</FormLabel>
<FormControl>
<Input
placeholder="Paste your Bitbucket API token"
type="password"
placeholder="ATBBPDYUC94nR96Nj7Cqpp4pfwKk03573DD2"
{...field}
/>
</FormControl>
@@ -215,7 +200,7 @@ export const AddBitbucketProvider = () => {
name="workspaceName"
render={({ field }) => (
<FormItem>
<FormLabel>Workspace Name (optional)</FormLabel>
<FormLabel>Workspace Name (Optional)</FormLabel>
<FormControl>
<Input
placeholder="For organization accounts"

View File

@@ -33,10 +33,7 @@ const Schema = z.object({
username: z.string().min(1, {
message: "Username is required",
}),
email: z.string().email().optional(),
workspaceName: z.string().optional(),
apiToken: z.string().optional(),
appPassword: z.string().optional(),
});
type Schema = z.infer<typeof Schema>;
@@ -63,28 +60,19 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
const form = useForm<Schema>({
defaultValues: {
username: "",
email: "",
workspaceName: "",
apiToken: "",
appPassword: "",
},
resolver: zodResolver(Schema),
});
const username = form.watch("username");
const email = form.watch("email");
const workspaceName = form.watch("workspaceName");
const apiToken = form.watch("apiToken");
const appPassword = form.watch("appPassword");
useEffect(() => {
form.reset({
username: bitbucket?.bitbucketUsername || "",
email: bitbucket?.bitbucketEmail || "",
workspaceName: bitbucket?.bitbucketWorkspaceName || "",
name: bitbucket?.gitProvider.name || "",
apiToken: bitbucket?.apiToken || "",
appPassword: bitbucket?.appPassword || "",
});
}, [form, isOpen, bitbucket]);
@@ -93,11 +81,8 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
bitbucketId,
gitProviderId: bitbucket?.gitProviderId || "",
bitbucketUsername: data.username,
bitbucketEmail: data.email || "",
bitbucketWorkspaceName: data.workspaceName || "",
name: data.name || "",
apiToken: data.apiToken || "",
appPassword: data.appPassword || "",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -136,12 +121,6 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
>
<CardContent className="p-0">
<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
control={form.control}
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
control={form.control}
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">
<Button
type="button"
@@ -262,10 +180,7 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
await testConnection({
bitbucketId,
bitbucketUsername: username,
bitbucketEmail: email,
workspaceName: workspaceName,
apiToken: apiToken,
appPassword: appPassword,
})
.then(async (message) => {
toast.info(`Message: ${message}`);

View File

@@ -30,9 +30,6 @@ const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
appName: z.string().min(1, {
message: "App Name is required",
}),
});
type Schema = z.infer<typeof Schema>;
@@ -58,7 +55,6 @@ export const EditGithubProvider = ({ githubId }: Props) => {
const form = useForm<Schema>({
defaultValues: {
name: "",
appName: "",
},
resolver: zodResolver(Schema),
});
@@ -66,7 +62,6 @@ export const EditGithubProvider = ({ githubId }: Props) => {
useEffect(() => {
form.reset({
name: github?.gitProvider.name || "",
appName: github?.githubAppName || "",
});
}, [form, isOpen]);
@@ -75,7 +70,6 @@ export const EditGithubProvider = ({ githubId }: Props) => {
githubId,
name: data.name || "",
gitProviderId: github?.gitProviderId || "",
githubAppName: data.appName || "",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -130,22 +124,6 @@ export const EditGithubProvider = ({ githubId }: Props) => {
</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">
<Button

View File

@@ -157,13 +157,7 @@ export const ShowGitProviders = () => {
</div>
</div>
<div className="flex flex-row gap-1 items-center">
{isBitbucket &&
gitProvider.bitbucket?.appPassword &&
!gitProvider.bitbucket?.apiToken ? (
<Badge variant="yellow">Deprecated</Badge>
) : null}
<div className="flex flex-row gap-1">
{!haveGithubRequirements && isGithub && (
<div className="flex flex-row gap-1 items-center">
<Badge

View File

@@ -1,14 +1,17 @@
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 { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
DiscordIcon,
GotifyIcon,
LarkIcon,
NtfyIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -107,12 +110,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
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 = {
@@ -128,20 +125,16 @@ export const notificationsMap = {
icon: <DiscordIcon />,
label: "Discord",
},
lark: {
icon: <LarkIcon className="text-muted-foreground" />,
label: "Lark",
},
email: {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
},
gotify: {
icon: <GotifyIcon />,
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
label: "Gotify",
},
ntfy: {
icon: <NtfyIcon />,
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
label: "ntfy",
},
};
@@ -177,8 +170,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
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
? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation();
@@ -197,9 +188,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
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>({
defaultValues: {
@@ -218,10 +206,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
});
useEffect(() => {
if (type === "email" && fields.length === 0) {
if (type === "email") {
append("");
}
}, [type, append, fields.length]);
}, [type, append]);
useEffect(() => {
if (notification) {
@@ -309,19 +297,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
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 {
@@ -336,7 +311,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
email: emailMutation,
gotify: gotifyMutation,
ntfy: ntfyMutation,
lark: larkMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -440,19 +414,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
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) {
@@ -541,7 +502,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
<Label
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.label}
@@ -1039,27 +1000,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{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 className="flex flex-col gap-4">
@@ -1210,8 +1150,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingDiscord ||
isLoadingEmail ||
isLoadingGotify ||
isLoadingNtfy ||
isLoadingLark
isLoadingNtfy
}
variant="secondary"
onClick={async () => {
@@ -1255,10 +1194,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
accessToken: form.getValues("accessToken"),
priority: form.getValues("priority"),
});
} else if (type === "lark") {
await testLarkConnection({
webhookUrl: form.getValues("webhookUrl"),
});
}
toast.success("Connection Success");
} catch {

View File

@@ -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 {
DiscordIcon,
GotifyIcon,
LarkIcon,
NtfyIcon,
SlackIcon,
TelegramIcon,
} from "@/components/icons/notification-icons";
@@ -36,7 +33,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email, Lark.
Telegram, Email.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -88,17 +85,12 @@ export const ShowNotifications = () => {
)}
{notification.notificationType === "gotify" && (
<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" />
<MessageCircleMore className="size-6 text-muted-foreground" />
</div>
)}

View File

@@ -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>
</>
);
};

View File

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

View File

@@ -1,6 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react";
import { Fingerprint, QrCode } from "lucide-react";
import QRCode from "qrcode";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -30,12 +29,6 @@ import {
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
@@ -61,26 +54,6 @@ type TwoFactorSetupData = {
type PasswordForm = z.infer<typeof PasswordSchema>;
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 = () => {
const utils = api.useUtils();
const [data, setData] = useState<TwoFactorSetupData | null>(null);
@@ -89,7 +62,6 @@ export const Enable2FA = () => {
const [step, setStep] = useState<"password" | "verify">("password");
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const [otpValue, setOtpValue] = useState("");
const { data: currentUser } = api.user.get.useQuery();
const handleVerifySubmit = async (e: React.FormEvent) => {
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 (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
@@ -340,7 +264,6 @@ export const Enable2FA = () => {
<span className="text-sm font-medium">
Scan this QR code with your authenticator app
</span>
{/** biome-ignore lint/performance/noImgElement: This is a valid use case for an img element */}
<img
src={data.qrCodeUrl}
alt="2FA QR Code"
@@ -358,46 +281,7 @@ export const Enable2FA = () => {
{backupCodes && backupCodes.length > 0 && (
<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>
<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>
<h4 className="font-medium">Backup Codes</h4>
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => (
<code

View File

@@ -29,14 +29,11 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import { Configure2FA } from "./configure-2fa";
import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa";
const profileSchema = z.object({
email: z
.string()
.email("Please enter a valid email address")
.min(1, "Email is required"),
email: z.string(),
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),
@@ -62,6 +59,7 @@ const randomImages = [
];
export const ProfileForm = () => {
const _utils = api.useUtils();
const { data, refetch, isLoading } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -119,27 +117,28 @@ export const ProfileForm = () => {
}, [form, data]);
const onSubmit = async (values: Profile) => {
try {
await mutateAsync({
email: values.email.toLowerCase(),
password: values.password || undefined,
image: values.image,
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
name: values.name || undefined,
await mutateAsync({
email: values.email.toLowerCase(),
password: values.password || undefined,
image: values.image,
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
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 (
@@ -156,8 +155,7 @@ export const ProfileForm = () => {
{t("settings.profile.description")}
</CardDescription>
</div>
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Disable2FA />}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -256,16 +254,8 @@ export const ProfileForm = () => {
onValueChange={(e) => {
field.onChange(e);
}}
defaultValue={
field.value?.startsWith("data:")
? "upload"
: field.value
}
value={
field.value?.startsWith("data:")
? "upload"
: field.value
}
defaultValue={field.value}
value={field.value}
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
>
<FormItem key="no-avatar">
@@ -286,72 +276,6 @@ export const ProfileForm = () => {
</Avatar>
</FormLabel>
</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) => (
<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">
@@ -362,7 +286,6 @@ export const ProfileForm = () => {
/>
</FormControl>
{/* biome-ignore lint/performance/noImgElement: this is an justified use of img element */}
<img
key={image}
src={image}

View File

@@ -1,6 +1,6 @@
import type { findEnvironmentById } from "@dokploy/server/index";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -161,13 +161,11 @@ const addPermissions = z.object({
canCreateServices: z.boolean().optional().default(false),
canDeleteProjects: z.boolean().optional().default(false),
canDeleteServices: z.boolean().optional().default(false),
canDeleteEnvironments: z.boolean().optional().default(false),
canAccessToTraefikFiles: z.boolean().optional().default(false),
canAccessToDocker: z.boolean().optional().default(false),
canAccessToAPI: z.boolean().optional().default(false),
canAccessToSSHKeys: z.boolean().optional().default(false),
canAccessToGitProviders: z.boolean().optional().default(false),
canCreateEnvironments: z.boolean().optional().default(false),
});
type AddPermissions = z.infer<typeof addPermissions>;
@@ -177,7 +175,6 @@ interface Props {
}
export const AddUserPermissions = ({ userId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: projects } = api.project.all.useQuery();
const { data, refetch } = api.user.one.useQuery(
@@ -195,25 +192,13 @@ export const AddUserPermissions = ({ userId }: Props) => {
const form = useForm<AddPermissions>({
defaultValues: {
accessedProjects: [],
accessedEnvironments: [],
accessedServices: [],
canDeleteEnvironments: false,
canCreateProjects: false,
canCreateServices: false,
canDeleteProjects: false,
canDeleteServices: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
canCreateEnvironments: false,
},
resolver: zodResolver(addPermissions),
});
useEffect(() => {
if (data && isOpen) {
if (data) {
form.reset({
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
@@ -222,16 +207,14 @@ export const AddUserPermissions = ({ userId }: Props) => {
canCreateServices: data.canCreateServices,
canDeleteProjects: data.canDeleteProjects,
canDeleteServices: data.canDeleteServices,
canDeleteEnvironments: data.canDeleteEnvironments || false,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
canAccessToDocker: data.canAccessToDocker,
canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
canAccessToGitProviders: data.canAccessToGitProviders,
canCreateEnvironments: data.canCreateEnvironments,
});
}
}, [form, form.reset, data, isOpen]);
}, [form, form.formState.isSubmitSuccessful, form.reset, data]);
const onSubmit = async (data: AddPermissions) => {
await mutateAsync({
@@ -240,7 +223,6 @@ export const AddUserPermissions = ({ userId }: Props) => {
canCreateProjects: data.canCreateProjects,
canDeleteServices: data.canDeleteServices,
canDeleteProjects: data.canDeleteProjects,
canDeleteEnvironments: data.canDeleteEnvironments,
canAccessToTraefikFiles: data.canAccessToTraefikFiles,
accessedProjects: data.accessedProjects || [],
accessedEnvironments: data.accessedEnvironments || [],
@@ -249,19 +231,17 @@ export const AddUserPermissions = ({ userId }: Props) => {
canAccessToAPI: data.canAccessToAPI,
canAccessToSSHKeys: data.canAccessToSSHKeys,
canAccessToGitProviders: data.canAccessToGitProviders,
canCreateEnvironments: data.canCreateEnvironments,
})
.then(async () => {
toast.success("Permissions updated");
refetch();
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating the permissions");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog>
<DialogTrigger className="" asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
@@ -363,46 +343,6 @@ export const AddUserPermissions = ({ userId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="canCreateEnvironments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Environments</FormLabel>
<FormDescription>
Allow the user to create environments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canDeleteEnvironments"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Delete Environments</FormLabel>
<FormDescription>
Allow the user to delete environments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="canAccessToTraefikFiles"

View File

@@ -5,7 +5,6 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -77,9 +76,6 @@ export const WebDomain = () => {
resolver: zodResolver(addServerDomain),
});
const https = form.watch("https");
const domain = form.watch("domain") || "";
const host = data?.user?.host || "";
const hasChanged = domain !== host;
useEffect(() => {
if (data) {
form.reset({
@@ -123,19 +119,6 @@ export const WebDomain = () => {
</div>
</CardHeader>
<CardContent className="space-y-2 py-6 border-t">
{/* Warning for GitHub webhook URL changes */}
{hasChanged && (
<AlertBlock type="warning">
<div className="space-y-2">
<p className="font-medium"> Important: URL Change Impact</p>
<p>
If you change the Dokploy Server URL make sure to update
your Github Apps to keep the auto-deploy working and preview
deployments working.
</p>
</div>
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

@@ -13,6 +13,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@@ -39,26 +40,18 @@ interface Props {
appName: string;
children?: React.ReactNode;
serverId?: string;
appType?: "stack" | "docker-compose";
}
export const DockerTerminalModal = ({
children,
appName,
serverId,
appType,
}: Props) => {
export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
serverId,
},
{
enabled: !!appName,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
@@ -90,7 +83,7 @@ export const DockerTerminalModal = ({
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="max-h-[85vh] sm:max-w-7xl"
className="max-h-[85vh] sm:max-w-7xl"
onEscapeKeyDown={(event) => event.preventDefault()}
>
<DialogHeader>
@@ -99,6 +92,7 @@ export const DockerTerminalModal = ({
Easy way to access to docker container
</DialogDescription>
</DialogHeader>
<Label>Select a container to view logs</Label>
<Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger>
{isLoading ? (

View File

@@ -75,21 +75,6 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
});
};
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && !canEdit) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading, canEdit]);
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>

View File

@@ -88,146 +88,3 @@ export const DiscordIcon = ({ className }: Props) => {
</svg>
);
};
export const LarkIcon = ({ className }: Props) => {
return (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-icon="LarkLogoColorful"
className={cn("size-9", className)}
>
<path
d="m12.924 12.803.056-.054c.038-.034.076-.072.11-.11l.077-.076.23-.227 1.334-1.319.335-.331c.063-.063.13-.123.195-.183a7.777 7.777 0 0 1 1.823-1.24 7.607 7.607 0 0 1 1.014-.4 13.177 13.177 0 0 0-2.5-5.013 1.203 1.203 0 0 0-.94-.448h-9.65c-.173 0-.246.224-.107.325a28.23 28.23 0 0 1 8 9.098c.007-.006.016-.013.023-.022Z"
fill="#00D6B9"
/>
<path
d="M9.097 21.299a13.258 13.258 0 0 0 11.82-7.247 5.576 5.576 0 0 1-.731 1.076 5.315 5.315 0 0 1-.745.7 5.117 5.117 0 0 1-.615.404 4.626 4.626 0 0 1-.726.331 5.312 5.312 0 0 1-1.883.312 5.892 5.892 0 0 1-.524-.031 6.509 6.509 0 0 1-.729-.126c-.06-.016-.12-.029-.18-.044-.166-.044-.33-.092-.494-.14-.082-.024-.164-.046-.246-.072-.123-.038-.247-.072-.366-.11l-.3-.095-.284-.094-.192-.067c-.08-.025-.155-.053-.234-.082a3.49 3.49 0 0 1-.167-.06c-.11-.04-.221-.079-.328-.12-.063-.025-.126-.047-.19-.072l-.252-.098c-.088-.035-.18-.07-.268-.107l-.174-.07c-.072-.028-.141-.06-.214-.088l-.164-.07c-.057-.024-.114-.05-.17-.075l-.149-.066-.135-.06-.14-.063a90.183 90.183 0 0 1-.141-.066 4.808 4.808 0 0 0-.18-.083c-.063-.028-.123-.06-.186-.088a5.697 5.697 0 0 1-.199-.098 27.762 27.762 0 0 1-8.067-5.969.18.18 0 0 0-.312.123l.006 9.21c0 .4.199.779.533 1a13.177 13.177 0 0 0 7.326 2.205Z"
fill="#3370FF"
/>
<path
d="M23.732 9.295a7.55 7.55 0 0 0-3.35-.776 7.521 7.521 0 0 0-2.284.35c-.054.016-.107.035-.158.05a8.297 8.297 0 0 0-.855.35 7.14 7.14 0 0 0-.552.297 6.716 6.716 0 0 0-.533.347c-.123.089-.243.18-.363.275-.13.104-.252.211-.375.321-.067.06-.13.123-.196.184l-.334.328-1.338 1.321-.23.228-.076.075c-.038.038-.076.073-.11.11l-.057.054a1.914 1.914 0 0 1-.085.08c-.032.028-.063.06-.095.088a13.286 13.286 0 0 1-2.748 1.946c.06.028.12.057.18.082l.142.066c.044.022.091.041.139.063l.135.06.149.067.17.075.164.07c.073.031.142.06.215.088.056.025.116.047.173.07.088.034.177.072.268.107.085.031.168.066.253.098l.189.072c.11.041.218.082.328.12.057.019.11.041.167.06.08.028.155.053.234.082l.192.066.284.095.3.095c.123.037.243.075.366.11l.246.072c.164.048.331.095.495.14.06.015.12.03.18.043.114.029.227.05.34.07.13.022.26.04.389.057a5.815 5.815 0 0 0 .994.019 5.172 5.172 0 0 0 1.413-.3 5.405 5.405 0 0 0 .726-.334c.06-.035.122-.07.182-.108a7.96 7.96 0 0 0 .432-.297 5.362 5.362 0 0 0 .577-.517 5.285 5.285 0 0 0 .37-.429 5.797 5.797 0 0 0 .527-.827l.13-.258 1.166-2.325-.003.006a7.391 7.391 0 0 1 1.527-2.186Z"
fill="#133C9A"
/>
</svg>
);
};
export const GotifyIcon = ({ className }: Props) => {
return (
<svg
viewBox="0 0 500 500"
className={cn("size-8", className)}
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<style>
{`
.gotify-st0{fill:#DDCBA2;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st1{fill:#71CAEE;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st2{fill:#FFFFFF;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st3{fill:#888E93;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st4{fill:#F0F0F0;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st5{fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;}
.gotify-st8{fill:#FFFFFF;}
`}
</style>
<linearGradient
id="gotify-gradient"
x1="265"
y1="280"
x2="275"
y2="302"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stopColor="#71CAEE" />
<stop offset="0.04" stopColor="#83CAE2" />
<stop offset="0.12" stopColor="#9FCACE" />
<stop offset="0.21" stopColor="#B6CBBE" />
<stop offset="0.31" stopColor="#C7CBB1" />
<stop offset="0.44" stopColor="#D4CBA8" />
<stop offset="0.61" stopColor="#DBCBA3" />
<stop offset="1" stopColor="#DDCBA2" />
</linearGradient>
</defs>
<g transform="matrix(2.33,0,0,2.33,-432,-323)">
<g transform="translate(-25,26)">
<path
className="gotify-st1"
d="m258.9,119.7c-3,-0.9-6,-1.8-9,-2.7-4.6,-1.4-9.2,-2.8-14,-2.5-2.8,0.2-6.1,1.3-6.9,4-0.6,2-1.6,7.3-1.3,7.9 1.5,3.4 13.9,6.7 18.3,6.7"
/>
<path d="m392.6,177.9c-1.4,1.4-2.2,3.5-2.5,5.5-0.2,1.4-0.1,3 0.5,4.3 0.6,1.3 1.8,2.3 3.1,3 1.3,0.6 2.8,0.9 4.3,0.9 1.1,0 2.3,-0.1 3.1,-0.9 0.6,-0.7 0.8,-1.6 0.9,-2.5 0.2,-2.3-0.1,-4.7-0.9,-6.9-0.4,-1.1-0.9,-2.3-1.8,-3.1-1.7,-1.8-4.5,-2.2-6.4,-0.5-0.1,0-0.2,0.1-0.3,0.2z" />
<path
className="gotify-st2"
d="m358.5,164.2c-1,-1 0,-2.7 1,-3.7 5.8,-5.2 15.1,-4.6 21.8,-0.6 10.9,6.6 15.6,19.9 17.2,32.5 0.6,5.2 0.9,10.6-0.5,15.7-1.4,5.1-4.6,9.9-9.3,12.1-1.1,0.5-2.3,0.9-3.4,0.5-1.1,-0.4-1.9,-1.8-1.2,-2.8-9.4,-13.6-19,-26.8-20.9,-43.2-0.5,-4.1-1.8,-7.4-4.7,-10.5z"
/>
<path
className="gotify-st1"
d="m220.1,133c34.6,-18 79.3,-19.6 112.2,-8.7 23.7,7.9 41.3,26.7 49.5,50 7.1,20.6 7.1,43.6 3,65.7-7.5,40.2-26.2,77.9-49,112.6-12.6,19-24.6,36-44.2,48.5-38.7,24.6-88.9,22.1-129.3,11.5-19.5,-5.1-38.4,-17.3-44.3,-37.3-3.8,-12.8-2.1,-27.6 4.6,-40 13.5,-24.8 46.2,-38.4 50.8,-67.9 1.4,-8.7-0.3,-17.3-1.6,-25.7-3.8,-23.4-5.4,-45.8 6.7,-68.7 9.5,-17.7 24.3,-31 41.7,-40z"
/>
<path
className="gotify-st2"
d="m264.5,174.9c-0.5,0.5-0.9,1-1.3,1.6-9,11.6-12,27.9-9.3,42.1 1.7,9 5.9,17.9 13.2,23.4 19.3,14.6 51.5,13.5 68.4,-1.5 24.4,-21.7 13,-67.6-14,-78.8-17.6,-7.2-43.7,-1.6-57,13.2z"
/>
<path
className="gotify-st2"
d="m382.1,237.1c1.4,-0.1 2.9,-0.1 4.3,0.1 0.3,0 0.7,0.1 1,0.4 0.2,0.3 0.4,0.7 0.5,1.1 1,3.9 0.5,8.2 0.1,12.4-0.1,0.9-0.2,1.8-0.6,2.6-1,2.1-3.1,2.7-4.7,2.7-0.1,0-0.2,0-0.3,-0.1-0.3,-0.2-0.3,-0.7-0.2,-1.2 0.3,-5.9-0.1,-11.9-0.1,-18z"
/>
<path
className="gotify-st2"
d="m378.7,236.8c-1.4,0.4-2.5,2-2.8,4.4-0.5,4.4-0.7,8.9-0.5,13.4 0,0.9 0.1,1.9 0.5,2.4 0.2,0.3 0.5,0.4 0.8,0.4 1.6,0.3 4.1,-0.6 5.6,-1 0,0 0,-5.2-0.1,-8-0.1,-2.8-0.1,-6.1-0.2,-8.9 0,-0.6 0,-1.5 0,-2.2 0.1,-0.7-2.6,-0.7-3.3,-0.5z"
/>
<path
className="gotify-st0"
d="m358.3,231.8c-0.3,2.2 0.1,4.7 1.7,7.4 2.6,4.4 7,6.1 11.9,5.8 8.9,-0.6 25.3,-5.4 27.5,-15.7 0.6,-3-0.3,-6.1-2.2,-8.5-6.2,-7.8-17.8,-5.7-25.6,-2-5.9,2.7-12.4,7-13.3,13z"
/>
<path
className="gotify-st3"
d="m386.4,208.6c2.2,1.4 3.7,3.8 4,7 0.3,3.6-1.4,7.5-5,8.8-2.9,1.1-6.2,0.6-9.1,-0.4-2.9,-1-5.8,-2.8-6.8,-5.7-0.7,-2-0.3,-4.3 0.7,-6.1 1.1,-1.8 2.8,-3.2 4.7,-4.1 3.9,-1.8 8.4,-1.6 11.5,0.5z"
/>
<path
className="gotify-st0"
d="m414.7,262.6c2.4,0.6 4.8,2.1 5.6,4.4 0.8,2.3 0.1,4.9-1.6,6.7-1.7,1.8-4.2,2.5-6.6,2.5-0.8,0-1.7,-0.1-2.4,-0.5-2.5,-1.1-3.5,-4-4.2,-6.6-1.8,-6.8 3.6,-7.8 9.2,-6.5z"
/>
<path
className="gotify-st4"
d="m267.1,284.7c2.3,-4.5 141.3,-36.2 144.7,-31.6 3.4,4.5 15.8,88.2 9,90.4-6.8,2.3-119.8,37.3-126.6,35-6.8,-2.3-29.4,-89.3-27.1,-93.8z"
/>
<path
className="gotify-st5"
d="m294.2,378.5c0,0 54.3,-74.6 59.9,-76.9 5.7,-2.3 67.3,41.3 67.3,41.3"
/>
<path
className="gotify-st4"
d="m267,287.7c0,0 86,38.8 91.6,36.6 5.7,-2.3 53.1,-71.2 53.1,-71.2"
/>
<path
fill="url(#gotify-gradient)"
d="m261.9,283.5c-0.1,4.2 4.3,7.3 8.4,7.6 4.1,0.3 8.2,-1.3 12.2,-2.6 1.4,-0.4 2.9,-0.8 4.2,-0.2 1.8,0.9 2.7,4.1 1.8,5.9-0.9,1.8-3.4,3.5-5.3,4.4-6.5,3-12.9,3.6-19.9,2-5.3,-1.2-11.3,-4.3-13,-13.5"
/>
<path d="m318.4,198.4c-2,-0.3-4.1,0.1-5.9,1.3-3.2,2.1-4.7,6.2-4.7,9.9 0,1.9 0.4,3.8 1.4,5.3 1.2,1.7 3.1,2.9 5.2,3.4 3.4,0.8 8.2,0.7 10.5,-2.5 1,-1.5 1.4,-3.3 1.5,-5.1 0.5,-5.7-1.8,-11.4-8,-12.3z" />
<path
className="gotify-st8"
d="m320.4,203.3c0.9,0.3 1.7,0.8 2.1,1.7 0.4,0.8 0.4,1.7 0.3,2.5-0.1,1-0.6,2-1.5,2.7-0.7,0.5-1.7,0.7-2.6,0.5-0.9,-0.2-1.7,-0.8-2.2,-1.6-1.1,-1.6-0.9,-4.4 0.9,-5.5 0.9,-0.4 2,-0.6 3,-0.3z"
/>
</g>
</g>
</svg>
);
};
export const NtfyIcon = ({ className }: Props) => {
return (
<svg
viewBox="0 0 24 24"
className={cn("size-8", className)}
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M12.597 13.693v2.156h6.205v-2.156ZM5.183 6.549v2.363l3.591 1.901 0.023 0.01 -0.023 0.009 -3.591 1.901v2.35l0.386 -0.211 5.456 -2.969V9.729ZM3.659 2.037C1.915 2.037 0.42 3.41 0.42 5.154v0.002L0.438 18.73 0 21.963l5.956 -1.583h14.806c1.744 0 3.238 -1.374 3.238 -3.118V5.154c0 -1.744 -1.493 -3.116 -3.237 -3.117h-0.001zm0 2.2h17.104c0.613 0.001 1.037 0.447 1.037 0.917v12.108c0 0.47 -0.424 0.916 -1.038 0.916H5.633l-3.026 0.915 0.031 -0.179 -0.017 -13.76c0 -0.47 0.424 -0.917 1.038 -0.917z"
/>
</svg>
);
};

View File

@@ -44,7 +44,6 @@ export const UserNav = () => {
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage
className="object-cover"
src={data?.user?.image || ""}
alt={data?.user?.image || ""}
/>

View File

@@ -39,19 +39,13 @@ export function AlertBlock({
<div
{...props}
className={cn(
"flex items-start flex-row gap-4 rounded-lg p-2",
"flex items-center flex-row gap-4 rounded-lg p-2",
iconClassName,
className,
)}
>
<div className="flex-shrink-0 mt-0.5">
{icon || <Icon className="text-current" />}
</div>
<div className="flex-1 min-w-0">
<span className="text-sm text-current break-words overflow-wrap-anywhere whitespace-pre-wrap">
{children}
</span>
</div>
{icon || <Icon className="text-current" />}
<span className="text-sm text-current">{children}</span>
</div>
);
}

View File

@@ -7,14 +7,7 @@ import {
import { cn } from "@/lib/utils";
interface Props {
status:
| "running"
| "error"
| "done"
| "idle"
| "cancelled"
| undefined
| null;
status: "running" | "error" | "done" | "idle" | undefined | null;
className?: string;
}
@@ -41,14 +34,6 @@ export const StatusTooltip = ({ status, className }: Props) => {
className={cn("size-3.5 rounded-full bg-green-500", className)}
/>
)}
{status === "cancelled" && (
<div
className={cn(
"size-3.5 rounded-full bg-muted-foreground",
className,
)}
/>
)}
{status === "running" && (
<div
className={cn("size-3.5 rounded-full bg-yellow-500", className)}
@@ -61,7 +46,6 @@ export const StatusTooltip = ({ status, className }: Props) => {
{status === "error" && "Error"}
{status === "done" && "Done"}
{status === "running" && "Running"}
{status === "cancelled" && "Cancelled"}
</span>
</TooltipContent>
</Tooltip>

View File

@@ -55,8 +55,6 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref,
) => {
const Comp = asChild ? Slot : "button";
const type = props.type ?? undefined;
return (
<>
<Comp
@@ -67,7 +65,6 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref={ref}
{...props}
disabled={isLoading || props.disabled}
type={type}
>
{isLoading && <Loader2 className="animate-spin" />}
<Slottable>{children}</Slottable>

View File

@@ -1 +0,0 @@
ALTER TABLE "bitbucket" ADD COLUMN "apiToken" text;

View File

@@ -1 +0,0 @@
ALTER TABLE "bitbucket" ADD COLUMN "bitbucketEmail" text;

View File

@@ -1 +0,0 @@
ALTER TYPE "public"."deploymentStatus" ADD VALUE 'cancelled';

View File

@@ -1,6 +0,0 @@
ALTER TABLE "application" ADD COLUMN "stopGracePeriodSwarm" bigint;--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "stopGracePeriodSwarm" bigint;--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "stopGracePeriodSwarm" bigint;--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "stopGracePeriodSwarm" bigint;--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "stopGracePeriodSwarm" bigint;--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "stopGracePeriodSwarm" bigint;

View File

@@ -1 +0,0 @@
ALTER TABLE "member" ADD COLUMN "canCreateEnvironments" boolean DEFAULT false NOT NULL;

View File

@@ -1 +0,0 @@
ALTER TABLE "member" ADD COLUMN "canDeleteEnvironments" boolean DEFAULT false NOT NULL;

View File

@@ -1,2 +0,0 @@
ALTER TABLE "application" ADD COLUMN "previewBuildSecrets" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "buildSecrets" text;

View File

@@ -1,8 +0,0 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'lark';--> statement-breakpoint
CREATE TABLE "lark" (
"larkId" text PRIMARY KEY NOT NULL,
"webhookUrl" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "larkId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_larkId_lark_larkId_fk" FOREIGN KEY ("larkId") REFERENCES "public"."lark"("larkId") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More