mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge branch 'canary' into feat/stack-env-support
This commit is contained in:
@@ -19,6 +19,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
||||
applicationType: z.literal("compose"),
|
||||
serverId: z.string().min(1),
|
||||
}),
|
||||
z.object({
|
||||
applicationId: z.string(),
|
||||
previewDeploymentId: z.string(),
|
||||
titleLog: z.string(),
|
||||
descriptionLog: z.string(),
|
||||
server: z.boolean().optional(),
|
||||
type: z.enum(["deploy"]),
|
||||
applicationType: z.literal("application-preview"),
|
||||
serverId: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type DeployJob = z.infer<typeof deployJobSchema>;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
deployRemoteApplication,
|
||||
deployRemoteCompose,
|
||||
deployRemotePreviewApplication,
|
||||
rebuildRemoteApplication,
|
||||
rebuildRemoteCompose,
|
||||
updateApplicationStatus,
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
} from "@dokploy/server";
|
||||
import type { DeployJob } from "./schema";
|
||||
|
||||
@@ -47,6 +49,20 @@ export const deploy = async (job: DeployJob) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (job.applicationType === "application-preview") {
|
||||
await updatePreviewDeployment(job.previewDeploymentId, {
|
||||
previewStatus: "running",
|
||||
});
|
||||
if (job.server) {
|
||||
if (job.type === "deploy") {
|
||||
await deployRemotePreviewApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog,
|
||||
descriptionLog: job.descriptionLog,
|
||||
previewDeploymentId: job.previewDeploymentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (job.applicationType === "application") {
|
||||
@@ -55,6 +71,10 @@ export const deploy = async (job: DeployJob) => {
|
||||
await updateCompose(job.composeId, {
|
||||
composeStatus: "error",
|
||||
});
|
||||
} else if (job.applicationType === "application-preview") {
|
||||
await updatePreviewDeployment(job.previewDeploymentId, {
|
||||
previewStatus: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ See the License for the specific language governing permissions and limitations
|
||||
|
||||
## Additional Terms for Specific Features
|
||||
|
||||
The following additional terms apply to the multi-node support, Docker Compose file and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||
|
||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support and Multi Server, will always be free to use in the self-hosted version.
|
||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||
|
||||
For further inquiries or permissions, please contact us directly.
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("createDomainLabels", () => {
|
||||
domainId: "",
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
previewDeploymentId: "",
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
@@ -39,6 +40,24 @@ describe("createDomainLabels", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("should add the path prefix if is different than / empty", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
{
|
||||
...baseDomain,
|
||||
path: "/hello",
|
||||
},
|
||||
"websecure",
|
||||
);
|
||||
|
||||
expect(labels).toEqual([
|
||||
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/hello`)",
|
||||
"traefik.http.routers.test-app-1-websecure.entrypoints=websecure",
|
||||
"traefik.http.services.test-app-1-websecure.loadbalancer.server.port=8080",
|
||||
"traefik.http.routers.test-app-1-websecure.service=test-app-1-websecure",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should add redirect middleware for https on web entrypoint", async () => {
|
||||
const httpsBaseDomain = { ...baseDomain, https: true };
|
||||
const labels = await createDomainLabels(appName, httpsBaseDomain, "web");
|
||||
|
||||
@@ -26,12 +26,31 @@ if (typeof window === "undefined") {
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
herokuVersion: "",
|
||||
applicationStatus: "done",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
serverId: "",
|
||||
registryUrl: "",
|
||||
branch: null,
|
||||
dockerBuildStage: "",
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
previewCertificateType: "none",
|
||||
previewEnv: null,
|
||||
previewHttps: false,
|
||||
previewPath: "/",
|
||||
previewPort: 3000,
|
||||
previewLimit: 0,
|
||||
previewWildcard: "",
|
||||
project: {
|
||||
env: "",
|
||||
adminId: "",
|
||||
name: "",
|
||||
description: "",
|
||||
createdAt: "",
|
||||
projectId: "",
|
||||
},
|
||||
buildArgs: null,
|
||||
buildPath: "/",
|
||||
gitlabPathNamespace: "",
|
||||
|
||||
179
apps/dokploy/__test__/env/shared.test.ts
vendored
Normal file
179
apps/dokploy/__test__/env/shared.test.ts
vendored
Normal file
@@ -0,0 +1,179 @@
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=staging
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
|
||||
PORT=3000
|
||||
`;
|
||||
const serviceEnv = `
|
||||
ENVIRONMENT=\${{project.ENVIRONMENT}}
|
||||
DATABASE_URL=\${{project.DATABASE_URL}}
|
||||
SERVICE_PORT=4000
|
||||
`;
|
||||
|
||||
describe("prepareEnvironmentVariables", () => {
|
||||
it("resolves project variables correctly", () => {
|
||||
const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"ENVIRONMENT=staging",
|
||||
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
|
||||
"SERVICE_PORT=4000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles undefined project variables", () => {
|
||||
const incompleteProjectEnv = `
|
||||
NODE_ENV=production
|
||||
`;
|
||||
|
||||
const invalidServiceEnv = `
|
||||
UNDEFINED_VAR=\${{project.UNDEFINED_VAR}}
|
||||
`;
|
||||
|
||||
expect(
|
||||
() =>
|
||||
prepareEnvironmentVariables(invalidServiceEnv, incompleteProjectEnv), // Cambiado el orden
|
||||
).toThrow("Invalid project environment variable: project.UNDEFINED_VAR");
|
||||
});
|
||||
it("allows service-specific variables to override project variables", () => {
|
||||
const serviceSpecificEnv = `
|
||||
ENVIRONMENT=production
|
||||
DATABASE_URL=\${{project.DATABASE_URL}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceSpecificEnv,
|
||||
projectEnv,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"ENVIRONMENT=production", // Overrides project variable
|
||||
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves complex references for dynamic endpoints", () => {
|
||||
const projectEnv = `
|
||||
BASE_URL=https://api.example.com
|
||||
API_VERSION=v1
|
||||
PORT=8000
|
||||
`;
|
||||
const serviceEnv = `
|
||||
API_ENDPOINT=\${{project.BASE_URL}}/\${{project.API_VERSION}}/endpoint
|
||||
SERVICE_PORT=9000
|
||||
`;
|
||||
const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"API_ENDPOINT=https://api.example.com/v1/endpoint",
|
||||
"SERVICE_PORT=9000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles missing project variables gracefully", () => {
|
||||
const projectEnv = `
|
||||
PORT=8080
|
||||
`;
|
||||
const serviceEnv = `
|
||||
MISSING_VAR=\${{project.MISSING_KEY}}
|
||||
SERVICE_PORT=3000
|
||||
`;
|
||||
|
||||
expect(() => prepareEnvironmentVariables(serviceEnv, projectEnv)).toThrow(
|
||||
"Invalid project environment variable: project.MISSING_KEY",
|
||||
);
|
||||
});
|
||||
|
||||
it("overrides project variables with service-specific values", () => {
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=staging
|
||||
DATABASE_URL=postgres://project:project@localhost:5432/project_db
|
||||
`;
|
||||
const serviceEnv = `
|
||||
ENVIRONMENT=\${{project.ENVIRONMENT}}
|
||||
DATABASE_URL=postgres://service:service@localhost:5432/service_db
|
||||
SERVICE_NAME=my-service
|
||||
`;
|
||||
const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"ENVIRONMENT=staging",
|
||||
"DATABASE_URL=postgres://service:service@localhost:5432/service_db",
|
||||
"SERVICE_NAME=my-service",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles project variables with normal and unusual characters", () => {
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=PRODUCTION
|
||||
`;
|
||||
|
||||
// Needs to be in quotes
|
||||
const serviceEnv = `
|
||||
NODE_ENV=\${{project.ENVIRONMENT}}
|
||||
SPECIAL_VAR="$^@$^@#$^@!#$@#$-\${{project.ENVIRONMENT}}"
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"NODE_ENV=PRODUCTION",
|
||||
"SPECIAL_VAR=$^@$^@#$^@!#$@#$-PRODUCTION",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles complex cases with multiple references, special characters, and spaces", () => {
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=STAGING
|
||||
APP_NAME=MyApp
|
||||
`;
|
||||
|
||||
const serviceEnv = `
|
||||
NODE_ENV=\${{project.ENVIRONMENT}}
|
||||
COMPLEX_VAR="Prefix-$#^!@-\${{project.ENVIRONMENT}}--\${{project.APP_NAME}} Suffix "
|
||||
`;
|
||||
const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"NODE_ENV=STAGING",
|
||||
"COMPLEX_VAR=Prefix-$#^!@-STAGING--MyApp Suffix ",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles references enclosed in single quotes", () => {
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=STAGING
|
||||
APP_NAME=MyApp
|
||||
`;
|
||||
|
||||
const serviceEnv = `
|
||||
NODE_ENV='\${{project.ENVIRONMENT}}'
|
||||
COMPLEX_VAR='Prefix-$#^!@-\${{project.ENVIRONMENT}}--\${{project.APP_NAME}} Suffix'
|
||||
`;
|
||||
const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"NODE_ENV=STAGING",
|
||||
"COMPLEX_VAR=Prefix-$#^!@-STAGING--MyApp Suffix",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles double and single quotes combined", () => {
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=PRODUCTION
|
||||
APP_NAME=MyApp
|
||||
`;
|
||||
const serviceEnv = `
|
||||
NODE_ENV="'\${{project.ENVIRONMENT}}'"
|
||||
COMPLEX_VAR="'Prefix \"DoubleQuoted\" and \${{project.APP_NAME}}'"
|
||||
`;
|
||||
const resolved = prepareEnvironmentVariables(serviceEnv, projectEnv);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"NODE_ENV='PRODUCTION'",
|
||||
"COMPLEX_VAR='Prefix \"DoubleQuoted\" and MyApp'",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,13 +6,32 @@ import { expect, test } from "vitest";
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
herokuVersion: "",
|
||||
applicationStatus: "done",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
serverId: "",
|
||||
branch: null,
|
||||
dockerBuildStage: "",
|
||||
registryUrl: "",
|
||||
buildArgs: null,
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
previewCertificateType: "none",
|
||||
previewEnv: null,
|
||||
previewHttps: false,
|
||||
previewPath: "/",
|
||||
previewPort: 3000,
|
||||
previewLimit: 0,
|
||||
previewWildcard: "",
|
||||
project: {
|
||||
env: "",
|
||||
adminId: "",
|
||||
name: "",
|
||||
description: "",
|
||||
createdAt: "",
|
||||
projectId: "",
|
||||
},
|
||||
buildPath: "/",
|
||||
gitlabPathNamespace: "",
|
||||
buildType: "nixpacks",
|
||||
@@ -86,6 +105,7 @@ const baseDomain: Domain = {
|
||||
composeId: "",
|
||||
domainType: "application",
|
||||
uniqueConfigKey: 1,
|
||||
previewDeploymentId: "",
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
|
||||
@@ -140,7 +140,7 @@ export const UpdatePort = ({ portId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Target Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="1-65535" {...field} />
|
||||
<NumberInput placeholder="1-65535" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -61,7 +61,7 @@ const redirectPresets = [
|
||||
redirect: {
|
||||
regex: "^https?://(?:www.)?(.+)",
|
||||
permanent: true,
|
||||
replacement: "https://www.$${1}",
|
||||
replacement: "https://www.${1}",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -70,7 +70,7 @@ const redirectPresets = [
|
||||
redirect: {
|
||||
regex: "^https?://www.(.+)",
|
||||
permanent: true,
|
||||
replacement: "https://$${1}",
|
||||
replacement: "https://${1}",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -150,7 +150,7 @@ export const AddVolumes = ({
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button>{children}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Volumes / Mounts</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -303,9 +303,12 @@ export const AddVolumes = ({
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Any content"
|
||||
className="h-64"
|
||||
<CodeEditor
|
||||
language="properties"
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
className="h-96 font-mono"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Pencil } from "lucide-react";
|
||||
@@ -119,7 +118,7 @@ export const UpdateVolume = ({
|
||||
} else if (typeForm === "file") {
|
||||
form.reset({
|
||||
content: data.content || "",
|
||||
mountPath: data.mountPath,
|
||||
mountPath: serviceType === "compose" ? "/" : data.mountPath,
|
||||
filePath: data.filePath || "",
|
||||
type: "file",
|
||||
});
|
||||
@@ -182,7 +181,7 @@ export const UpdateVolume = ({
|
||||
<Pencil className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update</DialogTitle>
|
||||
<DialogDescription>Update the mount</DialogDescription>
|
||||
@@ -247,9 +246,12 @@ export const UpdateVolume = ({
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Any content"
|
||||
className="h-64"
|
||||
<CodeEditor
|
||||
language="properties"
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
className="h-96 font-mono"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -296,15 +298,13 @@ export const UpdateVolume = ({
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-volume"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
// form="hook-form-update-volume"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -41,6 +41,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("heroku_buildpacks"),
|
||||
herokuVersion: z.string().nullable().default(""),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("paketo_buildpacks"),
|
||||
@@ -90,6 +91,13 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
dockerBuildStage: data.dockerBuildStage || "",
|
||||
}),
|
||||
});
|
||||
} else if (data.buildType === "heroku_buildpacks") {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
...(data.buildType && {
|
||||
herokuVersion: data.herokuVersion || "",
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
@@ -110,6 +118,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
data.buildType === "dockerfile" ? data.dockerContextPath : null,
|
||||
dockerBuildStage:
|
||||
data.buildType === "dockerfile" ? data.dockerBuildStage : null,
|
||||
herokuVersion:
|
||||
data.buildType === "heroku_buildpacks" ? data.herokuVersion : null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Build type saved");
|
||||
@@ -200,6 +210,28 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{buildType === "heroku_buildpacks" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="herokuVersion"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Heroku Version (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"Heroku Version (Default: 24)"}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{buildType === "dockerfile" && (
|
||||
<>
|
||||
<FormField
|
||||
|
||||
@@ -1,63 +1,143 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const deleteApplicationSchema = z.object({
|
||||
projectName: z.string().min(1, {
|
||||
message: "Application name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type DeleteApplication = z.infer<typeof deleteApplicationSchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const DeleteApplication = ({ applicationId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { mutateAsync, isLoading } = api.application.delete.useMutation();
|
||||
const { data } = api.application.one.useQuery(
|
||||
{ applicationId },
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
const { push } = useRouter();
|
||||
const form = useForm<DeleteApplication>({
|
||||
defaultValues: {
|
||||
projectName: "",
|
||||
},
|
||||
resolver: zodResolver(deleteApplicationSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: DeleteApplication) => {
|
||||
const expectedName = `${data?.name}/${data?.appName}`;
|
||||
if (formData.projectName === expectedName) {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
})
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
toast.success("Application deleted successfully");
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the application");
|
||||
});
|
||||
} else {
|
||||
form.setError("projectName", {
|
||||
message: "Project name does not match",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
application
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
})
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
|
||||
toast.success("Application delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete Application");
|
||||
});
|
||||
application. If you are sure please enter the application name to
|
||||
delete this application.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-delete-application"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter application name to confirm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-delete-application"
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
||||
refetchInterval: 1000,
|
||||
},
|
||||
);
|
||||
|
||||
const [url, setUrl] = React.useState("");
|
||||
useEffect(() => {
|
||||
setUrl(document.location.origin);
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex w-full flex-col gap-5 "
|
||||
>
|
||||
<Card className="bg-background">
|
||||
<Card className="bg-background p-6">
|
||||
<Secrets
|
||||
name="env"
|
||||
title="Environment Settings"
|
||||
|
||||
@@ -202,7 +202,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
@@ -281,7 +280,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
" w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
|
||||
@@ -21,6 +21,7 @@ const DockerProviderSchema = z.object({
|
||||
}),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
registryURL: z.string().optional(),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
@@ -33,12 +34,12 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync } = api.application.saveDockerProvider.useMutation();
|
||||
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {
|
||||
dockerImage: "",
|
||||
password: "",
|
||||
username: "",
|
||||
registryURL: "",
|
||||
},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
@@ -49,6 +50,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
dockerImage: data.dockerImage || "",
|
||||
password: data.password || "",
|
||||
username: data.username || "",
|
||||
registryURL: data.registryUrl || "",
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -59,6 +61,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
password: values.password || null,
|
||||
applicationId,
|
||||
username: values.username || null,
|
||||
registryUrl: values.registryURL || null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Docker Provider Saved");
|
||||
@@ -76,7 +79,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4 ">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerImage"
|
||||
@@ -91,6 +94,19 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="registryURL"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Registry URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Registry URL" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -193,7 +193,6 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
@@ -272,7 +271,6 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
" w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
|
||||
@@ -209,7 +209,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
@@ -297,7 +296,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
" w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
|
||||
@@ -2,9 +2,9 @@ import { ShowBuildChooseForm } from "@/components/dashboard/application/build/sh
|
||||
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { CheckCircle2, Terminal } from "lucide-react";
|
||||
import { Terminal } from "lucide-react";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
@@ -39,27 +39,6 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
appName={data?.appName || ""}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
aria-label="Toggle italic"
|
||||
pressed={data?.autoDeploy || false}
|
||||
onPressedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center"
|
||||
>
|
||||
Autodeploy
|
||||
{data?.autoDeploy && <CheckCircle2 className="size-4" />}
|
||||
</Toggle>
|
||||
<RedbuildApplication applicationId={applicationId} />
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<StartApplication applicationId={applicationId} />
|
||||
@@ -75,6 +54,27 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle italic"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ShowProviderForm applicationId={applicationId} />
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input, NumberInput } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { domain } from "@/server/db/validations/domain";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dices } from "lucide-react";
|
||||
import type z from "zod";
|
||||
|
||||
type Domain = z.infer<typeof domain>;
|
||||
|
||||
interface Props {
|
||||
previewDeploymentId: string;
|
||||
domainId?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AddPreviewDomain = ({
|
||||
previewDeploymentId,
|
||||
domainId = "",
|
||||
children,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { data, refetch } = api.domain.one.useQuery(
|
||||
{
|
||||
domainId,
|
||||
},
|
||||
{
|
||||
enabled: !!domainId,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: previewDeployment } = api.previewDeployment.one.useQuery(
|
||||
{
|
||||
previewDeploymentId,
|
||||
},
|
||||
{
|
||||
enabled: !!previewDeploymentId,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } = domainId
|
||||
? api.domain.update.useMutation()
|
||||
: api.domain.create.useMutation();
|
||||
|
||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const form = useForm<Domain>({
|
||||
resolver: zodResolver(domain),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
...data,
|
||||
/* Convert null to undefined */
|
||||
path: data?.path || undefined,
|
||||
port: data?.port || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!domainId) {
|
||||
form.reset({});
|
||||
}
|
||||
}, [form, form.reset, data, isLoading]);
|
||||
|
||||
const dictionary = {
|
||||
success: domainId ? "Domain Updated" : "Domain Created",
|
||||
error: domainId
|
||||
? "Error to update the domain"
|
||||
: "Error to create the domain",
|
||||
submit: domainId ? "Update" : "Create",
|
||||
dialogDescription: domainId
|
||||
? "In this section you can edit a domain"
|
||||
: "In this section you can add domains",
|
||||
};
|
||||
|
||||
const onSubmit = async (data: Domain) => {
|
||||
await mutateAsync({
|
||||
domainId,
|
||||
previewDeploymentId,
|
||||
...data,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(dictionary.success);
|
||||
await utils.previewDeployment.all.invalidate({
|
||||
applicationId: previewDeployment?.applicationId,
|
||||
});
|
||||
|
||||
if (domainId) {
|
||||
refetch();
|
||||
}
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(dictionary.error);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger className="" asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domain</DialogTitle>
|
||||
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-8 "
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input placeholder="api.dokploy.com" {...field} />
|
||||
</FormControl>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingGenerate}
|
||||
onClick={() => {
|
||||
generateDomain({
|
||||
appName: previewDeployment?.appName || "",
|
||||
serverId:
|
||||
previewDeployment?.application
|
||||
?.serverId || "",
|
||||
})
|
||||
.then((domain) => {
|
||||
field.onChange(domain);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Dices className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>Generate traefik.me domain</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"/"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Container Port</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput placeholder={"3000"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="https"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically provision SSL Certificate.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.getValues().https && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Certificate</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Letsencrypt (Default)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button isLoading={isLoading} form="hook-form" type="submit">
|
||||
{dictionary.submit}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import type { RouterOutputs } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
import { ShowDeployment } from "../deployments/show-deployment";
|
||||
|
||||
interface Props {
|
||||
deployments: RouterOutputs["deployment"]["all"];
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => {
|
||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">View Builds</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Preview Builds</DialogTitle>
|
||||
<DialogDescription>
|
||||
See all the preview builds for this application on this Pull Request
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
{deployments?.map((deployment) => (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{deployment.status}
|
||||
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.title}
|
||||
</span>
|
||||
{deployment.description && (
|
||||
<span className="break-all text-sm text-muted-foreground">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment.logPath);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<ShowDeployment
|
||||
serverId={serverId || ""}
|
||||
open={activeLog !== null}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { Pencil, RocketIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ShowDeployment } from "../deployments/show-deployment";
|
||||
import Link from "next/link";
|
||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { AddPreviewDomain } from "./add-preview-domain";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { ShowPreviewSettings } from "./show-preview-settings";
|
||||
import { ShowPreviewBuilds } from "./show-preview-builds";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||
const { data } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||
api.previewDeployment.delete.useMutation();
|
||||
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
|
||||
api.previewDeployment.all.useQuery(
|
||||
{ applicationId },
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
);
|
||||
// const [url, setUrl] = React.useState("");
|
||||
// useEffect(() => {
|
||||
// setUrl(document.location.origin);
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl">Preview Deployments</CardTitle>
|
||||
<CardDescription>See all the preview deployments</CardDescription>
|
||||
</div>
|
||||
{data?.isPreviewDeploymentsActive && (
|
||||
<ShowPreviewSettings applicationId={applicationId} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{data?.isPreviewDeploymentsActive ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<span>
|
||||
Preview deployments are a way to test your application before it
|
||||
is deployed to production. It will create a new deployment for
|
||||
each pull request you create.
|
||||
</span>
|
||||
</div>
|
||||
{data?.previewDeployments?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<RocketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No preview deployments found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{previewDeployments?.map((previewDeployment) => {
|
||||
const { deployments, domain } = previewDeployment;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={previewDeployment?.previewDeploymentId}
|
||||
className="flex flex-col justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex justify-between gap-2 max-sm:flex-wrap">
|
||||
<div className="flex flex-col gap-2">
|
||||
{deployments?.length === 0 ? (
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No deployments found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{previewDeployment?.pullRequestTitle}
|
||||
</span>
|
||||
<StatusTooltip
|
||||
status={previewDeployment.previewStatus}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{previewDeployment?.pullRequestTitle && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all text-sm text-muted-foreground w-fit">
|
||||
Title: {previewDeployment?.pullRequestTitle}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewDeployment?.pullRequestURL && (
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubIcon />
|
||||
<Link
|
||||
target="_blank"
|
||||
href={previewDeployment?.pullRequestURL}
|
||||
className="break-all text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
|
||||
>
|
||||
Pull Request URL
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col ">
|
||||
<span>Domain </span>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`http://${domain?.host}`}
|
||||
className="text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
|
||||
>
|
||||
{domain?.host}
|
||||
</Link>
|
||||
<AddPreviewDomain
|
||||
previewDeploymentId={
|
||||
previewDeployment.previewDeploymentId
|
||||
}
|
||||
domainId={domain?.domainId}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<Pencil className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AddPreviewDomain>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:items-end gap-2 max-sm:w-full">
|
||||
{previewDeployment?.createdAt && (
|
||||
<div className="text-sm capitalize text-muted-foreground">
|
||||
<DateTooltip
|
||||
date={previewDeployment?.createdAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ShowPreviewBuilds
|
||||
deployments={previewDeployment?.deployments || []}
|
||||
serverId={data?.serverId || ""}
|
||||
/>
|
||||
|
||||
<ShowModalLogs
|
||||
appName={previewDeployment.appName}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button variant="outline">View Logs</Button>
|
||||
</ShowModalLogs>
|
||||
|
||||
<DialogAction
|
||||
title="Delete Preview"
|
||||
description="Are you sure you want to delete this preview?"
|
||||
onClick={() => {
|
||||
deletePreviewDeployment({
|
||||
previewDeploymentId:
|
||||
previewDeployment.previewDeploymentId,
|
||||
})
|
||||
.then(() => {
|
||||
refetchPreviewDeployments();
|
||||
toast.success("Preview deployment deleted");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="destructive" isLoading={isLoading}>
|
||||
Delete Preview
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<RocketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
Preview deployments are disabled for this application, please
|
||||
enable it
|
||||
</span>
|
||||
<ShowPreviewSettings applicationId={applicationId} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
import { api } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input, NumberInput } from "@/components/ui/input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Secrets } from "@/components/ui/secrets";
|
||||
import { toast } from "sonner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const schema = z.object({
|
||||
env: z.string(),
|
||||
buildArgs: z.string(),
|
||||
wildcardDomain: z.string(),
|
||||
port: z.number(),
|
||||
previewLimit: z.number(),
|
||||
previewHttps: z.boolean(),
|
||||
previewPath: z.string(),
|
||||
previewCertificateType: z.enum(["letsencrypt", "none"]),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const { mutateAsync: updateApplication, isLoading } =
|
||||
api.application.update.useMutation();
|
||||
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
env: "",
|
||||
wildcardDomain: "*.traefik.me",
|
||||
port: 3000,
|
||||
previewLimit: 3,
|
||||
previewHttps: false,
|
||||
previewPath: "/",
|
||||
previewCertificateType: "none",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const previewHttps = form.watch("previewHttps");
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||
}, [data?.isPreviewDeploymentsActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
env: data.previewEnv || "",
|
||||
buildArgs: data.previewBuildArgs || "",
|
||||
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
||||
port: data.previewPort || 3000,
|
||||
previewLimit: data.previewLimit || 3,
|
||||
previewHttps: data.previewHttps || false,
|
||||
previewPath: data.previewPath || "/",
|
||||
previewCertificateType: data.previewCertificateType || "none",
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const onSubmit = async (formData: Schema) => {
|
||||
updateApplication({
|
||||
previewEnv: formData.env,
|
||||
previewBuildArgs: formData.buildArgs,
|
||||
previewWildcard: formData.wildcardDomain,
|
||||
previewPort: formData.port,
|
||||
applicationId,
|
||||
previewLimit: formData.previewLimit,
|
||||
previewHttps: formData.previewHttps,
|
||||
previewPath: formData.previewPath,
|
||||
previewCertificateType: formData.previewCertificateType,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Preview Deployments settings updated");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">View Settings</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Preview Deployment Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Adjust the settings for preview deployments of this application,
|
||||
including environment variables, build options, and deployment
|
||||
rules.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-delete-application"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wildcardDomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Wildcard Domain</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="*.traefik.me" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="previewPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Preview Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput placeholder="3000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="previewLimit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Preview Limit</FormLabel>
|
||||
{/* <FormDescription>
|
||||
Set the limit of preview deployments that can be
|
||||
created for this app.
|
||||
</FormDescription> */}
|
||||
<FormControl>
|
||||
<NumberInput placeholder="3000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="previewHttps"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically provision SSL Certificate.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{previewHttps && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="previewCertificateType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Certificate</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Letsencrypt (Default)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-row items-center justify-between rounded-lg border p-4 col-span-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Enable preview deployments
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Enable or disable preview deployments for this
|
||||
application.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
updateApplication({
|
||||
isPreviewDeploymentsActive: checked,
|
||||
applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Preview deployments enabled");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="env"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Secrets
|
||||
name="env"
|
||||
title="Environment Settings"
|
||||
description="You can add environment variables to your resource."
|
||||
placeholder={[
|
||||
"NODE_ENV=production",
|
||||
"PORT=3000",
|
||||
].join("\n")}
|
||||
/>
|
||||
{/* <CodeEditor
|
||||
lineWrapping
|
||||
language="properties"
|
||||
wrapperClassName="h-[25rem] font-mono"
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
`}
|
||||
{...field}
|
||||
/> */}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
name="buildArgs"
|
||||
title="Build-time Variables"
|
||||
description={
|
||||
<span>
|
||||
Available only at build-time. See documentation
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.docker.com/build/guide/build-args/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
placeholder="NPM_TOKEN=xyz"
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-delete-application"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,63 +1,141 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const deleteComposeSchema = z.object({
|
||||
projectName: z.string().min(1, {
|
||||
message: "Compose name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type DeleteCompose = z.infer<typeof deleteComposeSchema>;
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const DeleteCompose = ({ composeId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { mutateAsync, isLoading } = api.compose.delete.useMutation();
|
||||
const { data } = api.compose.one.useQuery(
|
||||
{ composeId },
|
||||
{ enabled: !!composeId },
|
||||
);
|
||||
const { push } = useRouter();
|
||||
const form = useForm<DeleteCompose>({
|
||||
defaultValues: {
|
||||
projectName: "",
|
||||
},
|
||||
resolver: zodResolver(deleteComposeSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: DeleteCompose) => {
|
||||
const expectedName = `${data?.name}/${data?.appName}`;
|
||||
if (formData.projectName === expectedName) {
|
||||
await mutateAsync({ composeId })
|
||||
.then((result) => {
|
||||
push(`/dashboard/project/${result?.projectId}`);
|
||||
toast.success("Compose deleted successfully");
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the compose");
|
||||
});
|
||||
} else {
|
||||
form.setError("projectName", {
|
||||
message: `Project name must match "${expectedName}"`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
compose and all its services.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
})
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
|
||||
toast.success("Compose delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete the compose");
|
||||
});
|
||||
compose. If you are sure please enter the compose name to delete
|
||||
this compose.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-delete-compose"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
</FormLabel>{" "}
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter compose name to confirm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-delete-compose"
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { CheckCircle2, ExternalLink, Globe, Terminal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
import { StartCompose } from "../start-compose";
|
||||
import { DeployCompose } from "./deploy-compose";
|
||||
import { RedbuildCompose } from "./rebuild-compose";
|
||||
import { StopCompose } from "./stop-compose";
|
||||
@@ -50,28 +51,11 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
||||
<DeployCompose composeId={composeId} />
|
||||
<Toggle
|
||||
aria-label="Toggle italic"
|
||||
pressed={data?.autoDeploy || false}
|
||||
onPressedChange={async (enabled) => {
|
||||
await update({
|
||||
composeId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center"
|
||||
>
|
||||
Autodeploy {data?.autoDeploy && <CheckCircle2 className="size-4" />}
|
||||
</Toggle>
|
||||
<RedbuildCompose composeId={composeId} />
|
||||
{["running", "done"].includes(data?.composeStatus || "") && (
|
||||
{data?.composeType === "docker-compose" &&
|
||||
data?.composeStatus === "idle" ? (
|
||||
<StartCompose composeId={composeId} />
|
||||
) : (
|
||||
<StopCompose composeId={composeId} />
|
||||
)}
|
||||
|
||||
@@ -84,6 +68,27 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle italic"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
composeId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center"
|
||||
/>
|
||||
</div>
|
||||
{domains.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -204,7 +204,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
@@ -283,7 +282,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
" w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
|
||||
@@ -195,7 +195,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
@@ -274,7 +273,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
" w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
|
||||
@@ -211,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
@@ -299,7 +298,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
" w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
|
||||
65
apps/dokploy/components/dashboard/compose/start-compose.tsx
Normal file
65
apps/dokploy/components/dashboard/compose/start-compose.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const StartCompose = ({ composeId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.compose.start.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="secondary" isLoading={isLoading}>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure to start the compose?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will start the compose
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.compose.one.invalidate({
|
||||
composeId,
|
||||
});
|
||||
toast.success("Compose started succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to start the Compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
65
apps/dokploy/components/dashboard/compose/stop-compose.tsx
Normal file
65
apps/dokploy/components/dashboard/compose/stop-compose.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { Ban } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const StopCompose = ({ composeId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.compose.stop.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" isLoading={isLoading}>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you absolutely sure to stop the compose?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will stop the compose
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.compose.one.invalidate({
|
||||
composeId,
|
||||
});
|
||||
toast.success("Compose stopped succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to stop the Compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -160,7 +160,6 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
|
||||
@@ -144,7 +144,6 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
|
||||
const Terminal = dynamic(
|
||||
() => import("./docker-terminal").then((e) => e.DockerTerminal),
|
||||
@@ -27,8 +30,27 @@ export const DockerTerminalModal = ({
|
||||
containerId,
|
||||
serverId,
|
||||
}: Props) => {
|
||||
const [mainDialogOpen, setMainDialogOpen] = useState(false);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
|
||||
const handleMainDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setConfirmDialogOpen(true);
|
||||
} else {
|
||||
setMainDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setConfirmDialogOpen(false);
|
||||
setMainDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setConfirmDialogOpen(false);
|
||||
};
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
@@ -50,6 +72,24 @@ export const DockerTerminalModal = ({
|
||||
containerId={containerId}
|
||||
serverId={serverId || ""}
|
||||
/>
|
||||
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Are you sure you want to close the terminal?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
By clicking the confirm button, the terminal will be closed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>Confirm</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,62 +1,140 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const deleteMariadbSchema = z.object({
|
||||
projectName: z.string().min(1, {
|
||||
message: "Database name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type DeleteMariadb = z.infer<typeof deleteMariadbSchema>;
|
||||
|
||||
interface Props {
|
||||
mariadbId: string;
|
||||
}
|
||||
|
||||
export const DeleteMariadb = ({ mariadbId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { mutateAsync, isLoading } = api.mariadb.remove.useMutation();
|
||||
const { data } = api.mariadb.one.useQuery(
|
||||
{ mariadbId },
|
||||
{ enabled: !!mariadbId },
|
||||
);
|
||||
const { push } = useRouter();
|
||||
const form = useForm<DeleteMariadb>({
|
||||
defaultValues: {
|
||||
projectName: "",
|
||||
},
|
||||
resolver: zodResolver(deleteMariadbSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: DeleteMariadb) => {
|
||||
const expectedName = `${data?.name}/${data?.appName}`;
|
||||
if (formData.projectName === expectedName) {
|
||||
await mutateAsync({ mariadbId })
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
toast.success("Database deleted successfully");
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the database");
|
||||
});
|
||||
} else {
|
||||
form.setError("projectName", {
|
||||
message: "Database name does not match",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground " />
|
||||
<TrashIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
database
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
mariadbId,
|
||||
})
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
toast.success("Database delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete the database");
|
||||
});
|
||||
database. If you are sure please enter the database name to delete
|
||||
this database.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-delete-mariadb"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter database name to confirm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-delete-mariadb"
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,62 +1,139 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const deleteMongoSchema = z.object({
|
||||
projectName: z.string().min(1, {
|
||||
message: "Database name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type DeleteMongo = z.infer<typeof deleteMongoSchema>;
|
||||
|
||||
interface Props {
|
||||
mongoId: string;
|
||||
}
|
||||
|
||||
// commen
|
||||
|
||||
export const DeleteMongo = ({ mongoId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { mutateAsync, isLoading } = api.mongo.remove.useMutation();
|
||||
const { data } = api.mongo.one.useQuery({ mongoId }, { enabled: !!mongoId });
|
||||
const { push } = useRouter();
|
||||
const form = useForm<DeleteMongo>({
|
||||
defaultValues: {
|
||||
projectName: "",
|
||||
},
|
||||
resolver: zodResolver(deleteMongoSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: DeleteMongo) => {
|
||||
const expectedName = `${data?.name}/${data?.appName}`;
|
||||
if (formData.projectName === expectedName) {
|
||||
await mutateAsync({ mongoId })
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
toast.success("Database deleted successfully");
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the database");
|
||||
});
|
||||
} else {
|
||||
form.setError("projectName", {
|
||||
message: "Database name does not match",
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground " />
|
||||
<TrashIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
database
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
mongoId,
|
||||
})
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
toast.success("Database delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete the database");
|
||||
});
|
||||
database. If you are sure please enter the database name to delete
|
||||
this database.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-delete-mongo"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter database name to confirm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-delete-mongo"
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,62 +1,138 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const deleteMysqlSchema = z.object({
|
||||
projectName: z.string().min(1, {
|
||||
message: "Database name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type DeleteMysql = z.infer<typeof deleteMysqlSchema>;
|
||||
|
||||
interface Props {
|
||||
mysqlId: string;
|
||||
}
|
||||
|
||||
export const DeleteMysql = ({ mysqlId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { mutateAsync, isLoading } = api.mysql.remove.useMutation();
|
||||
const { data } = api.mysql.one.useQuery({ mysqlId }, { enabled: !!mysqlId });
|
||||
const { push } = useRouter();
|
||||
const form = useForm<DeleteMysql>({
|
||||
defaultValues: {
|
||||
projectName: "",
|
||||
},
|
||||
resolver: zodResolver(deleteMysqlSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: DeleteMysql) => {
|
||||
const expectedName = `${data?.name}/${data?.appName}`;
|
||||
if (formData.projectName === expectedName) {
|
||||
await mutateAsync({ mysqlId })
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
toast.success("Database deleted successfully");
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the database");
|
||||
});
|
||||
} else {
|
||||
form.setError("projectName", {
|
||||
message: "Database name does not match",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground " />
|
||||
<TrashIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
database
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
mysqlId,
|
||||
})
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
toast.success("Database delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete the database");
|
||||
});
|
||||
database. If you are sure please enter the database name to delete
|
||||
this database.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-delete-mysql"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter database name to confirm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-delete-mysql"
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -86,14 +86,12 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
|
||||
)}
|
||||
|
||||
{mount.type === "file" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Content</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.content}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Content</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{mount.content}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{mount.type === "bind" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -1,62 +1,141 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const deletePostgresSchema = z.object({
|
||||
projectName: z.string().min(1, {
|
||||
message: "Database name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type DeletePostgres = z.infer<typeof deletePostgresSchema>;
|
||||
|
||||
interface Props {
|
||||
postgresId: string;
|
||||
}
|
||||
|
||||
export const DeletePostgres = ({ postgresId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { mutateAsync, isLoading } = api.postgres.remove.useMutation();
|
||||
const { data } = api.postgres.one.useQuery(
|
||||
{ postgresId },
|
||||
{ enabled: !!postgresId },
|
||||
);
|
||||
const { push } = useRouter();
|
||||
const form = useForm<DeletePostgres>({
|
||||
defaultValues: {
|
||||
projectName: "",
|
||||
},
|
||||
resolver: zodResolver(deletePostgresSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: DeletePostgres) => {
|
||||
const expectedName = `${data?.name}/${data?.appName}`;
|
||||
if (formData.projectName === expectedName) {
|
||||
await mutateAsync({ postgresId })
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
toast.success("Database deleted successfully");
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the database");
|
||||
});
|
||||
} else {
|
||||
form.setError("projectName", {
|
||||
message: "Database name does not match",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground " />
|
||||
<TrashIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
database
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
postgresId,
|
||||
})
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
toast.success("Database delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete the database");
|
||||
});
|
||||
database. If you are sure please enter the database name to delete
|
||||
this database.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-delete-postgres"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter database name to confirm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-delete-postgres"
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -127,7 +127,6 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
"md:max-w-[15rem] w-full justify-between !bg-input",
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { FileIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const updateProjectSchema = z.object({
|
||||
env: z.string().optional(),
|
||||
});
|
||||
|
||||
type UpdateProject = z.infer<typeof updateProjectSchema>;
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProjectEnviroment = ({ projectId, children }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isLoading } =
|
||||
api.project.update.useMutation();
|
||||
const { data } = api.project.one.useQuery(
|
||||
{
|
||||
projectId,
|
||||
},
|
||||
{
|
||||
enabled: !!projectId,
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<UpdateProject>({
|
||||
defaultValues: {
|
||||
env: data?.env ?? "",
|
||||
},
|
||||
resolver: zodResolver(updateProjectSchema),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
env: data.env ?? "",
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: UpdateProject) => {
|
||||
await mutateAsync({
|
||||
env: formData.env || "",
|
||||
projectId: projectId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Project env updated succesfully");
|
||||
utils.project.all.invalidate();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the env");
|
||||
})
|
||||
.finally(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{children ?? (
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<FileIcon className="size-4" />
|
||||
<span>Project Enviroment</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-6xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Project Enviroment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the env Environment variables that are accessible to all
|
||||
services of this project. Use this syntax to reference project-level
|
||||
variables in your service environments:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<AlertBlock type="info">
|
||||
Use this syntax to reference project-level variables in your service
|
||||
environments: <code>DATABASE_URL=${"{{project.DATABASE_URL}}"}</code>
|
||||
</AlertBlock>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid items-center gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="env"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enviroment variables</FormLabel>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
lineWrapping
|
||||
language="properties"
|
||||
wrapperClassName="h-[35rem] font-mono"
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<pre>
|
||||
<FormMessage />
|
||||
</pre>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -25,7 +25,6 @@ import { api } from "@/utils/api";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookIcon,
|
||||
CircuitBoard,
|
||||
ExternalLink,
|
||||
ExternalLinkIcon,
|
||||
FolderInput,
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ProjectEnviroment } from "./project-enviroment";
|
||||
import { UpdateProject } from "./update";
|
||||
|
||||
export const ShowProjects = () => {
|
||||
@@ -190,7 +190,11 @@ export const ShowProjects = () => {
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ProjectEnviroment
|
||||
projectId={project.projectId}
|
||||
/>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<UpdateProject projectId={project.projectId} />
|
||||
</div>
|
||||
|
||||
@@ -1,62 +1,138 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const deleteRedisSchema = z.object({
|
||||
projectName: z.string().min(1, {
|
||||
message: "Database name is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type DeleteRedis = z.infer<typeof deleteRedisSchema>;
|
||||
|
||||
interface Props {
|
||||
redisId: string;
|
||||
}
|
||||
|
||||
export const DeleteRedis = ({ redisId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { mutateAsync, isLoading } = api.redis.remove.useMutation();
|
||||
const { data } = api.redis.one.useQuery({ redisId }, { enabled: !!redisId });
|
||||
const { push } = useRouter();
|
||||
const form = useForm<DeleteRedis>({
|
||||
defaultValues: {
|
||||
projectName: "",
|
||||
},
|
||||
resolver: zodResolver(deleteRedisSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: DeleteRedis) => {
|
||||
const expectedName = `${data?.name}/${data?.appName}`;
|
||||
if (formData.projectName === expectedName) {
|
||||
await mutateAsync({ redisId })
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
toast.success("Database deleted successfully");
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the database");
|
||||
});
|
||||
} else {
|
||||
form.setError("projectName", {
|
||||
message: "Database name does not match",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground " />
|
||||
<TrashIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
database
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
redisId,
|
||||
})
|
||||
.then((data) => {
|
||||
push(`/dashboard/project/${data?.projectId}`);
|
||||
toast.success("Database delete succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to delete the database");
|
||||
});
|
||||
database. If you are sure please enter the database name to delete
|
||||
this database.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-delete-redis"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter database name to confirm"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-delete-redis"
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -49,8 +49,11 @@ export const columns: ColumnDef<LogEntry>[] = [
|
||||
const log = row.original;
|
||||
return (
|
||||
<div className=" flex flex-col gap-2">
|
||||
<div className="flex flex-row gap-3 ">
|
||||
<div className="flex items-center flex-row gap-3 ">
|
||||
{log.RequestMethod}{" "}
|
||||
<div className="inline-flex items-center gap-2 bg-muted p-1 rounded">
|
||||
<span>{log.RequestAddr}</span>
|
||||
</div>
|
||||
{log.RequestPath.length > 100
|
||||
? `${log.RequestPath.slice(0, 82)}...`
|
||||
: log.RequestPath}
|
||||
|
||||
@@ -20,6 +20,16 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Languages } from "@/lib/languages";
|
||||
import useLocale from "@/utils/hooks/use-locale";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -28,6 +38,9 @@ const appearanceFormSchema = z.object({
|
||||
theme: z.enum(["light", "dark", "system"], {
|
||||
required_error: "Please select a theme.",
|
||||
}),
|
||||
language: z.nativeEnum(Languages, {
|
||||
required_error: "Please select a language.",
|
||||
}),
|
||||
});
|
||||
|
||||
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
|
||||
@@ -35,10 +48,14 @@ type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
|
||||
// This can come from your database or API.
|
||||
const defaultValues: Partial<AppearanceFormValues> = {
|
||||
theme: "system",
|
||||
language: Languages.English,
|
||||
};
|
||||
|
||||
export function AppearanceForm() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const { locale, setLocale } = useLocale();
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const form = useForm<AppearanceFormValues>({
|
||||
resolver: zodResolver(appearanceFormSchema),
|
||||
defaultValues,
|
||||
@@ -47,19 +64,23 @@ export function AppearanceForm() {
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
theme: (theme ?? "system") as AppearanceFormValues["theme"],
|
||||
language: locale,
|
||||
});
|
||||
}, [form, theme]);
|
||||
}, [form, theme, locale]);
|
||||
function onSubmit(data: AppearanceFormValues) {
|
||||
setTheme(data.theme);
|
||||
setLocale(data.language);
|
||||
toast.success("Preferences Updated");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Appearance</CardTitle>
|
||||
<CardTitle className="text-xl">
|
||||
{t("settings.appearance.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the theme of your dashboard.
|
||||
{t("settings.appearance.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
@@ -72,9 +93,9 @@ export function AppearanceForm() {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="space-y-1 ">
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<FormLabel>{t("settings.appearance.theme")}</FormLabel>
|
||||
<FormDescription>
|
||||
Select a theme for your dashboard
|
||||
{t("settings.appearance.themeDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
<RadioGroup
|
||||
@@ -92,7 +113,7 @@ export function AppearanceForm() {
|
||||
<img src="/images/theme-light.svg" alt="light" />
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
Light
|
||||
{t("settings.appearance.themes.light")}
|
||||
</span>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
@@ -105,7 +126,7 @@ export function AppearanceForm() {
|
||||
<img src="/images/theme-dark.svg" alt="dark" />
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
Dark
|
||||
{t("settings.appearance.themes.dark")}
|
||||
</span>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
@@ -121,7 +142,7 @@ export function AppearanceForm() {
|
||||
<img src="/images/theme-system.svg" alt="system" />
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
System
|
||||
{t("settings.appearance.themes.system")}
|
||||
</span>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
@@ -131,7 +152,44 @@ export function AppearanceForm() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="submit">Save</Button>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
defaultValue={form.control._defaultValues.language}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="space-y-1">
|
||||
<FormLabel>{t("settings.appearance.language")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("settings.appearance.languageDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No preset selected" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(Languages).map((preset) => {
|
||||
const value =
|
||||
Languages[preset as keyof typeof Languages];
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{preset}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="submit">{t("settings.common.save")}</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
|
||||
@@ -89,18 +89,14 @@ export const ShowBilling = () => {
|
||||
<div className="pb-5">
|
||||
<Progress value={safePercentage} className="max-w-lg" />
|
||||
</div>
|
||||
{admin && (
|
||||
<>
|
||||
{admin.serversQuantity! <= servers?.length! && (
|
||||
<div className="flex flex-row gap-4 p-2 bg-yellow-50 dark:bg-yellow-950 rounded-lg items-center">
|
||||
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
You have reached the maximum number of servers you can
|
||||
create, please upgrade your plan to add more servers.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{admin && admin.serversQuantity! <= servers?.length! && (
|
||||
<div className="flex flex-row gap-4 p-2 bg-yellow-50 dark:bg-yellow-950 rounded-lg items-center">
|
||||
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
You have reached the maximum number of servers you can create,
|
||||
please upgrade your plan to add more servers.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -188,7 +184,6 @@ export const ShowBilling = () => {
|
||||
</p>
|
||||
|
||||
<ul
|
||||
role="list"
|
||||
className={clsx(
|
||||
" mt-4 flex flex-col gap-y-2 text-sm",
|
||||
featured ? "text-white" : "text-slate-200",
|
||||
|
||||
@@ -28,11 +28,7 @@ export const ShowRegistry = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
{data && data?.length > 0 && (
|
||||
<>
|
||||
<AddRegistry />
|
||||
</>
|
||||
)}
|
||||
{data && data?.length > 0 && <AddRegistry />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-4 h-full">
|
||||
|
||||
@@ -34,9 +34,11 @@ import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { S3_PROVIDERS } from "./constants";
|
||||
|
||||
const addDestination = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
provider: z.string().optional(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
bucket: z.string(),
|
||||
@@ -58,6 +60,7 @@ export const AddDestination = () => {
|
||||
api.destination.testConnection.useMutation();
|
||||
const form = useForm<AddDestination>({
|
||||
defaultValues: {
|
||||
provider: "",
|
||||
accessKeyId: "",
|
||||
bucket: "",
|
||||
name: "",
|
||||
@@ -73,6 +76,7 @@ export const AddDestination = () => {
|
||||
|
||||
const onSubmit = async (data: AddDestination) => {
|
||||
await mutateAsync({
|
||||
provider: data.provider || "",
|
||||
accessKey: data.accessKeyId,
|
||||
bucket: data.bucket,
|
||||
endpoint: data.endpoint,
|
||||
@@ -123,6 +127,40 @@ export const AddDestination = () => {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a S3 Provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{S3_PROVIDERS.map((s3Provider) => (
|
||||
<SelectItem
|
||||
key={s3Provider.key}
|
||||
value={s3Provider.key}
|
||||
>
|
||||
{s3Provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -255,6 +293,7 @@ export const AddDestination = () => {
|
||||
isLoading={isLoading}
|
||||
onClick={async () => {
|
||||
await testConnection({
|
||||
provider: form.getValues("provider") || "",
|
||||
accessKey: form.getValues("accessKeyId"),
|
||||
bucket: form.getValues("bucket"),
|
||||
endpoint: form.getValues("endpoint"),
|
||||
@@ -283,6 +322,7 @@ export const AddDestination = () => {
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
await testConnection({
|
||||
provider: form.getValues("provider") || "",
|
||||
accessKey: form.getValues("accessKeyId"),
|
||||
bucket: form.getValues("bucket"),
|
||||
endpoint: form.getValues("endpoint"),
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
export const S3_PROVIDERS: Array<{
|
||||
key: string;
|
||||
name: string;
|
||||
}> = [
|
||||
{
|
||||
key: "AWS",
|
||||
name: "Amazon Web Services (AWS) S3",
|
||||
},
|
||||
{
|
||||
key: "Alibaba",
|
||||
name: "Alibaba Cloud Object Storage System (OSS) formerly Aliyun",
|
||||
},
|
||||
{
|
||||
key: "ArvanCloud",
|
||||
name: "Arvan Cloud Object Storage (AOS)",
|
||||
},
|
||||
{
|
||||
key: "Ceph",
|
||||
name: "Ceph Object Storage",
|
||||
},
|
||||
{
|
||||
key: "ChinaMobile",
|
||||
name: "China Mobile Ecloud Elastic Object Storage (EOS)",
|
||||
},
|
||||
{
|
||||
key: "Cloudflare",
|
||||
name: "Cloudflare R2 Storage",
|
||||
},
|
||||
{
|
||||
key: "DigitalOcean",
|
||||
name: "DigitalOcean Spaces",
|
||||
},
|
||||
{
|
||||
key: "Dreamhost",
|
||||
name: "Dreamhost DreamObjects",
|
||||
},
|
||||
{
|
||||
key: "GCS",
|
||||
name: "Google Cloud Storage",
|
||||
},
|
||||
{
|
||||
key: "HuaweiOBS",
|
||||
name: "Huawei Object Storage Service",
|
||||
},
|
||||
{
|
||||
key: "IBMCOS",
|
||||
name: "IBM COS S3",
|
||||
},
|
||||
{
|
||||
key: "IDrive",
|
||||
name: "IDrive e2",
|
||||
},
|
||||
{
|
||||
key: "IONOS",
|
||||
name: "IONOS Cloud",
|
||||
},
|
||||
{
|
||||
key: "LyveCloud",
|
||||
name: "Seagate Lyve Cloud",
|
||||
},
|
||||
{
|
||||
key: "Leviia",
|
||||
name: "Leviia Object Storage",
|
||||
},
|
||||
{
|
||||
key: "Liara",
|
||||
name: "Liara Object Storage",
|
||||
},
|
||||
{
|
||||
key: "Linode",
|
||||
name: "Linode Object Storage",
|
||||
},
|
||||
{
|
||||
key: "Magalu",
|
||||
name: "Magalu Object Storage",
|
||||
},
|
||||
{
|
||||
key: "Minio",
|
||||
name: "Minio Object Storage",
|
||||
},
|
||||
{
|
||||
key: "Netease",
|
||||
name: "Netease Object Storage (NOS)",
|
||||
},
|
||||
{
|
||||
key: "Petabox",
|
||||
name: "Petabox Object Storage",
|
||||
},
|
||||
{
|
||||
key: "RackCorp",
|
||||
name: "RackCorp Object Storage",
|
||||
},
|
||||
{
|
||||
key: "Rclone",
|
||||
name: "Rclone S3 Server",
|
||||
},
|
||||
{
|
||||
key: "Scaleway",
|
||||
name: "Scaleway Object Storage",
|
||||
},
|
||||
{
|
||||
key: "SeaweedFS",
|
||||
name: "SeaweedFS S3",
|
||||
},
|
||||
{
|
||||
key: "StackPath",
|
||||
name: "StackPath Object Storage",
|
||||
},
|
||||
{
|
||||
key: "Storj",
|
||||
name: "Storj (S3 Compatible Gateway)",
|
||||
},
|
||||
{
|
||||
key: "Synology",
|
||||
name: "Synology C2 Object Storage",
|
||||
},
|
||||
{
|
||||
key: "TencentCOS",
|
||||
name: "Tencent Cloud Object Storage (COS)",
|
||||
},
|
||||
{
|
||||
key: "Wasabi",
|
||||
name: "Wasabi Object Storage",
|
||||
},
|
||||
{
|
||||
key: "Qiniu",
|
||||
name: "Qiniu Object Storage (Kodo)",
|
||||
},
|
||||
{
|
||||
key: "Other",
|
||||
name: "Any other S3 compatible provider",
|
||||
},
|
||||
];
|
||||
@@ -35,9 +35,11 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { S3_PROVIDERS } from "./constants";
|
||||
|
||||
const updateDestination = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
provider: z.string().optional(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
bucket: z.string(),
|
||||
@@ -70,6 +72,7 @@ export const UpdateDestination = ({ destinationId }: Props) => {
|
||||
api.destination.testConnection.useMutation();
|
||||
const form = useForm<UpdateDestination>({
|
||||
defaultValues: {
|
||||
provider: "",
|
||||
accessKeyId: "",
|
||||
bucket: "",
|
||||
name: "",
|
||||
@@ -152,6 +155,40 @@ export const UpdateDestination = ({ destinationId }: Props) => {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a S3 Provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{S3_PROVIDERS.map((s3Provider) => (
|
||||
<SelectItem
|
||||
key={s3Provider.key}
|
||||
value={s3Provider.key}
|
||||
>
|
||||
{s3Provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -285,6 +322,7 @@ export const UpdateDestination = ({ destinationId }: Props) => {
|
||||
variant={"secondary"}
|
||||
onClick={async () => {
|
||||
await testConnection({
|
||||
provider: form.getValues("provider") || "",
|
||||
accessKey: form.getValues("accessKeyId"),
|
||||
bucket: form.getValues("bucket"),
|
||||
endpoint: form.getValues("endpoint"),
|
||||
@@ -311,6 +349,7 @@ export const UpdateDestination = ({ destinationId }: Props) => {
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
await testConnection({
|
||||
provider: form.getValues("provider") || "",
|
||||
accessKey: form.getValues("accessKeyId"),
|
||||
bucket: form.getValues("bucket"),
|
||||
endpoint: form.getValues("endpoint"),
|
||||
|
||||
@@ -53,7 +53,7 @@ export const AddGithubProvider = () => {
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary" className="flex items-center space-x-1">
|
||||
<GithubIcon />
|
||||
<GithubIcon className="text-current fill-current" />
|
||||
<span>Github</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -397,25 +397,23 @@ export const AddNotification = () => {
|
||||
)}
|
||||
|
||||
{type === "discord" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === "email" && (
|
||||
@@ -669,7 +667,7 @@ export const AddNotification = () => {
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Dokploy Restart</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a dokploy is restarted.
|
||||
Trigger the action when dokploy is restarted.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
|
||||
@@ -356,25 +356,23 @@ export const UpdateNotification = ({ notificationId }: Props) => {
|
||||
)}
|
||||
|
||||
{type === "discord" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{type === "email" && (
|
||||
<>
|
||||
|
||||
@@ -16,9 +16,11 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { generateSHA256Hash } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -51,6 +53,15 @@ const randomImages = [
|
||||
export const ProfileForm = () => {
|
||||
const { data, refetch } = api.auth.get.useQuery();
|
||||
const { mutateAsync, isLoading } = api.auth.update.useMutation();
|
||||
const { t } = useTranslation("settings");
|
||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||
|
||||
const availableAvatars = useMemo(() => {
|
||||
if (gravatarHash === null) return randomImages;
|
||||
return randomImages.concat([
|
||||
`https://www.gravatar.com/avatar/${gravatarHash}`,
|
||||
]);
|
||||
}, [gravatarHash]);
|
||||
|
||||
const form = useForm<Profile>({
|
||||
defaultValues: {
|
||||
@@ -68,6 +79,12 @@ export const ProfileForm = () => {
|
||||
password: "",
|
||||
image: data?.image || "",
|
||||
});
|
||||
|
||||
if (data.email) {
|
||||
generateSHA256Hash(data.email).then((hash) => {
|
||||
setGravatarHash(hash);
|
||||
});
|
||||
}
|
||||
}
|
||||
form.reset();
|
||||
}, [form, form.reset, data]);
|
||||
@@ -91,10 +108,10 @@ export const ProfileForm = () => {
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Account</CardTitle>
|
||||
<CardDescription>
|
||||
Change the details of your profile here.
|
||||
</CardDescription>
|
||||
<CardTitle className="text-xl">
|
||||
{t("settings.profile.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("settings.profile.description")}</CardDescription>
|
||||
</div>
|
||||
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
|
||||
</CardHeader>
|
||||
@@ -107,9 +124,12 @@ export const ProfileForm = () => {
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormLabel>{t("settings.profile.email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Email" {...field} />
|
||||
<Input
|
||||
placeholder={t("settings.profile.email")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -120,11 +140,11 @@ export const ProfileForm = () => {
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>{t("settings.profile.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
placeholder={t("settings.profile.password")}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
@@ -139,7 +159,7 @@ export const ProfileForm = () => {
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Avatar</FormLabel>
|
||||
<FormLabel>{t("settings.profile.avatar")}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={(e) => {
|
||||
@@ -149,7 +169,7 @@ export const ProfileForm = () => {
|
||||
value={field.value}
|
||||
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
||||
>
|
||||
{randomImages.map((image) => (
|
||||
{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">
|
||||
<FormControl>
|
||||
@@ -177,7 +197,7 @@ export const ProfileForm = () => {
|
||||
</div>
|
||||
<div>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
{t("settings.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
|
||||
import { UpdateServerIp } from "@/components/dashboard/settings/web-server/update-server-ip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -11,10 +12,13 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { ShowModalLogs } from "../../web-server/show-modal-logs";
|
||||
import { GPUSupportModal } from "../gpu-support-modal";
|
||||
|
||||
export const ShowDokployActions = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { mutateAsync: reloadServer, isLoading } =
|
||||
api.settings.reloadServer.useMutation();
|
||||
|
||||
@@ -22,11 +26,13 @@ export const ShowDokployActions = () => {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={isLoading}>
|
||||
<Button isLoading={isLoading} variant="outline">
|
||||
Server
|
||||
{t("settings.server.webServer.server.label")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("settings.server.webServer.actions")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
@@ -39,12 +45,27 @@ export const ShowDokployActions = () => {
|
||||
toast.success("Server Reloaded");
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span>Reload</span>
|
||||
<span>{t("settings.server.webServer.reload")}</span>
|
||||
</DropdownMenuItem>
|
||||
<ShowModalLogs appName="dokploy">
|
||||
<span>Watch logs</span>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{t("settings.server.webServer.watchLogs")}
|
||||
</DropdownMenuItem>
|
||||
</ShowModalLogs>
|
||||
<GPUSupportModal />
|
||||
<UpdateServerIp>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{t("settings.server.webServer.updateServerIp")}
|
||||
</DropdownMenuItem>
|
||||
</UpdateServerIp>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -21,13 +20,13 @@ export const ShowServerActions = ({ serverId }: Props) => {
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
View Actions
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl overflow-y-auto max-h-screen ">
|
||||
<DialogContent className="sm:max-w-xl overflow-y-auto max-h-screen">
|
||||
<div className="flex flex-col gap-1">
|
||||
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
||||
<DialogDescription>Reload or clean the web server.</DialogDescription>
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
|
||||
api.settings.cleanAll.useMutation();
|
||||
|
||||
@@ -64,11 +66,13 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Space
|
||||
{t("settings.server.webServer.storage.label")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="start">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("settings.server.webServer.actions")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
@@ -85,7 +89,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean unused images</span>
|
||||
<span>
|
||||
{t("settings.server.webServer.storage.cleanUnusedImages")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
@@ -101,7 +107,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean unused volumes</span>
|
||||
<span>
|
||||
{t("settings.server.webServer.storage.cleanUnusedVolumes")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -118,7 +126,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean stopped containers</span>
|
||||
<span>
|
||||
{t("settings.server.webServer.storage.cleanStoppedContainers")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -135,7 +145,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean Docker Builder & System</span>
|
||||
<span>
|
||||
{t("settings.server.webServer.storage.cleanDockerBuilder")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{!serverId && (
|
||||
<DropdownMenuItem
|
||||
@@ -150,7 +162,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean Monitoring </span>
|
||||
<span>
|
||||
{t("settings.server.webServer.storage.cleanMonitoring")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -168,7 +182,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean all</span>
|
||||
<span>{t("settings.server.webServer.storage.cleanAll")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
|
||||
import { ShowModalLogs } from "../../web-server/show-modal-logs";
|
||||
|
||||
@@ -30,6 +31,7 @@ interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
|
||||
api.settings.reloadTraefik.useMutation();
|
||||
|
||||
@@ -51,11 +53,13 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
variant="outline"
|
||||
>
|
||||
Traefik
|
||||
{t("settings.server.webServer.traefik.label")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("settings.server.webServer.actions")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
@@ -70,18 +74,24 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
toast.error("Error to reload the traefik");
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span>Reload</span>
|
||||
<span>{t("settings.server.webServer.reload")}</span>
|
||||
</DropdownMenuItem>
|
||||
<ShowModalLogs appName="dokploy-traefik" serverId={serverId}>
|
||||
<span>Watch logs</span>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t("settings.server.webServer.watchLogs")}
|
||||
</DropdownMenuItem>
|
||||
</ShowModalLogs>
|
||||
<EditTraefikEnv serverId={serverId}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span>Modify Env</span>
|
||||
<span>{t("settings.server.webServer.traefik.modifyEnv")}</span>
|
||||
</DropdownMenuItem>
|
||||
</EditTraefikEnv>
|
||||
|
||||
|
||||
@@ -23,29 +23,27 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
const enabled = data?.enableDockerCleanup || server?.enableDockerCleanup;
|
||||
|
||||
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
enableDockerCleanup: checked,
|
||||
serverId: serverId,
|
||||
});
|
||||
if (serverId) {
|
||||
await refetchServer();
|
||||
} else {
|
||||
await refetch();
|
||||
}
|
||||
toast.success("Docker Cleanup updated");
|
||||
} catch (error) {
|
||||
toast.error("Docker Cleanup Error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={async (e) => {
|
||||
await mutateAsync({
|
||||
enableDockerCleanup: e,
|
||||
serverId: serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Docker Cleanup Enabled");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Docker Cleanup Error");
|
||||
});
|
||||
|
||||
if (serverId) {
|
||||
refetchServer();
|
||||
} else {
|
||||
refetch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Switch checked={!!enabled} onCheckedChange={handleToggle} />
|
||||
<Label className="text-primary">Daily Docker Cleanup</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { useState } from "react";
|
||||
import { GPUSupport } from "./gpu-support";
|
||||
|
||||
export const GPUSupportModal = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<span>GPU Setup</span>
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-4xl overflow-y-auto max-h-screen">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
Dokploy Server GPU Setup
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<GPUSupport serverId="" />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,282 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
import { CheckCircle2, Cpu, Loader2, RefreshCw, XCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface GPUSupportProps {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export function GPUSupport({ serverId }: GPUSupportProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const utils = api.useContext();
|
||||
|
||||
const {
|
||||
data: gpuStatus,
|
||||
isLoading: isChecking,
|
||||
refetch,
|
||||
} = api.settings.checkGPUStatus.useQuery(
|
||||
{ serverId },
|
||||
{
|
||||
enabled: serverId !== undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const setupGPU = api.settings.setupGPU.useMutation({
|
||||
onMutate: () => {
|
||||
setIsLoading(true);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("GPU support enabled successfully");
|
||||
setIsLoading(false);
|
||||
await utils.settings.checkGPUStatus.invalidate({ serverId });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
error.message ||
|
||||
"Failed to enable GPU support. Please check server logs.",
|
||||
);
|
||||
setIsLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await utils.settings.checkGPUStatus.invalidate({ serverId });
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error("Failed to refresh GPU status");
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
handleRefresh();
|
||||
}, []);
|
||||
|
||||
const handleEnableGPU = async () => {
|
||||
if (serverId === undefined) {
|
||||
toast.error("No server selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await setupGPU.mutateAsync({ serverId });
|
||||
} catch (error) {
|
||||
// Error handling is done in mutation's onError
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-row gap-2 justify-between w-full items-end max-sm:flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="size-5" />
|
||||
<CardTitle className="text-xl">GPU Configuration</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Configure and monitor GPU support
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogAction
|
||||
title="Enable GPU Support?"
|
||||
description="This will enable GPU support for Docker Swarm on this server. Make sure you have the required hardware and drivers installed."
|
||||
onClick={handleEnableGPU}
|
||||
>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading || serverId === undefined || isChecking}
|
||||
>
|
||||
{isLoading
|
||||
? "Enabling GPU..."
|
||||
: gpuStatus?.swarmEnabled
|
||||
? "Reconfigure GPU"
|
||||
: "Enable GPU"}
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={isChecking || isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-5 w-5 ${isChecking || isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<AlertBlock type="info">
|
||||
<div className="font-medium mb-2">System Requirements:</div>
|
||||
<ul className="list-disc list-inside text-sm space-y-1">
|
||||
<li>NVIDIA GPU hardware must be physically installed</li>
|
||||
<li>
|
||||
NVIDIA drivers must be installed and running (check with
|
||||
nvidia-smi)
|
||||
</li>
|
||||
<li>
|
||||
NVIDIA Container Runtime must be installed
|
||||
(nvidia-container-runtime)
|
||||
</li>
|
||||
<li>User must have sudo/administrative privileges</li>
|
||||
<li>System must support CUDA for GPU acceleration</li>
|
||||
</ul>
|
||||
</AlertBlock>
|
||||
|
||||
{isChecking ? (
|
||||
<div className="flex items-center justify-center text-muted-foreground py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Checking GPU status...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{/* Prerequisites Section */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-1">Prerequisites</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Shows all software checks and available hardware
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
label="NVIDIA Driver"
|
||||
isEnabled={gpuStatus?.driverInstalled}
|
||||
description={
|
||||
gpuStatus?.driverVersion
|
||||
? `Installed (v${gpuStatus.driverVersion})`
|
||||
: "Not Installed"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="GPU Model"
|
||||
value={gpuStatus?.gpuModel || "Not Detected"}
|
||||
showIcon={false}
|
||||
/>
|
||||
<StatusRow
|
||||
label="GPU Memory"
|
||||
value={gpuStatus?.memoryInfo || "Not Available"}
|
||||
showIcon={false}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Available GPUs"
|
||||
value={gpuStatus?.availableGPUs || 0}
|
||||
showIcon={false}
|
||||
/>
|
||||
<StatusRow
|
||||
label="CUDA Support"
|
||||
isEnabled={gpuStatus?.cudaSupport}
|
||||
description={
|
||||
gpuStatus?.cudaVersion
|
||||
? `Available (v${gpuStatus.cudaVersion})`
|
||||
: "Not Available"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="NVIDIA Container Runtime"
|
||||
isEnabled={gpuStatus?.runtimeInstalled}
|
||||
description={
|
||||
gpuStatus?.runtimeInstalled
|
||||
? "Installed"
|
||||
: "Not Installed"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Status */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-1">
|
||||
Docker Swarm GPU Status
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Shows the configuration state that changes with the Enable
|
||||
GPU
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
label="Runtime Configuration"
|
||||
isEnabled={gpuStatus?.runtimeConfigured}
|
||||
description={
|
||||
gpuStatus?.runtimeConfigured
|
||||
? "Default Runtime"
|
||||
: "Not Default Runtime"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Swarm GPU Support"
|
||||
isEnabled={gpuStatus?.swarmEnabled}
|
||||
description={
|
||||
gpuStatus?.swarmEnabled
|
||||
? `Enabled (${gpuStatus.gpuResources} GPU${gpuStatus.gpuResources !== 1 ? "s" : ""})`
|
||||
: "Not Enabled"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatusRowProps {
|
||||
label: string;
|
||||
isEnabled?: boolean;
|
||||
description?: string;
|
||||
value?: string | number;
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
export function StatusRow({
|
||||
label,
|
||||
isEnabled,
|
||||
description,
|
||||
value,
|
||||
showIcon = true,
|
||||
}: StatusRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{showIcon ? (
|
||||
<>
|
||||
{isEnabled ? (
|
||||
<CheckCircle2 className="size-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="size-4 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={`text-sm ${isEnabled ? "text-green-500" : "text-red-500"}`}
|
||||
>
|
||||
{description || (isEnabled ? "Installed" : "Not Installed")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{value}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ShowDeployment } from "../../application/deployments/show-deployment";
|
||||
import { GPUSupport } from "./gpu-support";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
@@ -89,9 +90,10 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
) : (
|
||||
<div id="hook-form-add-gitlab" className="grid w-full gap-1">
|
||||
<Tabs defaultValue="ssh-keys">
|
||||
<TabsList className="grid grid-cols-2 w-[400px]">
|
||||
<TabsList className="grid grid-cols-3 w-[400px]">
|
||||
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="ssh-keys"
|
||||
@@ -291,6 +293,14 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="gpu-setup"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||
<GPUSupport serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -98,7 +98,7 @@ export const ShowServers = () => {
|
||||
)
|
||||
)}
|
||||
{data && data?.length > 0 && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-6 overflow-auto">
|
||||
<Table>
|
||||
<TableCaption>See all servers</TableCaption>
|
||||
<TableHeader>
|
||||
@@ -228,21 +228,17 @@ export const ShowServers = () => {
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
|
||||
{isActive && (
|
||||
{isActive && server.sshKeyId && (
|
||||
<>
|
||||
{server.sshKeyId && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Extra</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Extra</DropdownMenuLabel>
|
||||
|
||||
<ShowTraefikFileSystemModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
<ShowDockerContainersModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ShowTraefikFileSystemModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
<ShowDockerContainersModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -49,6 +50,7 @@ const addServerDomain = z
|
||||
type AddServerDomain = z.infer<typeof addServerDomain>;
|
||||
|
||||
export const WebDomain = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { data: user, refetch } = api.admin.one.useQuery();
|
||||
const { mutateAsync, isLoading } =
|
||||
api.settings.assignDomainServer.useMutation();
|
||||
@@ -89,9 +91,11 @@ export const WebDomain = () => {
|
||||
<div className="w-full">
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Server Domain</CardTitle>
|
||||
<CardTitle className="text-xl">
|
||||
{t("settings.server.domain.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add a domain to your server application.
|
||||
{t("settings.server.domain.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
@@ -106,7 +110,9 @@ export const WebDomain = () => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Domain</FormLabel>
|
||||
<FormLabel>
|
||||
{t("settings.server.domain.form.domain")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
@@ -126,7 +132,9 @@ export const WebDomain = () => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Letsencrypt Email</FormLabel>
|
||||
<FormLabel>
|
||||
{t("settings.server.domain.form.letsEncryptEmail")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
@@ -145,20 +153,32 @@ export const WebDomain = () => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Certificate</FormLabel>
|
||||
<FormLabel>
|
||||
{t("settings.server.domain.form.certificate.label")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate" />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"settings.server.domain.form.certificate.placeholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={"none"}>None</SelectItem>
|
||||
<SelectItem value={"none"}>
|
||||
{t(
|
||||
"settings.server.domain.form.certificateOptions.none",
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Letsencrypt (Default)
|
||||
{t(
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt",
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -169,7 +189,7 @@ export const WebDomain = () => {
|
||||
/>
|
||||
<div>
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
{t("settings.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import React from "react";
|
||||
import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
|
||||
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
|
||||
@@ -18,6 +19,7 @@ interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export const WebServer = ({ className }: Props) => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { data } = api.admin.one.useQuery();
|
||||
|
||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||
@@ -25,8 +27,12 @@ export const WebServer = ({ className }: Props) => {
|
||||
return (
|
||||
<Card className={cn("rounded-lg w-full bg-transparent p-0", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Web server settings</CardTitle>
|
||||
<CardDescription>Reload or clean the web server.</CardDescription>
|
||||
<CardTitle className="text-xl">
|
||||
{t("settings.server.webServer.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("settings.server.webServer.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 ">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -49,14 +51,34 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
|
||||
},
|
||||
);
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
const [mainDialogOpen, setMainDialogOpen] = useState(false);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
|
||||
const handleMainDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setConfirmDialogOpen(true);
|
||||
} else {
|
||||
setMainDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setConfirmDialogOpen(false);
|
||||
setMainDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setConfirmDialogOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data?.length > 0) {
|
||||
setContainerId(data[0]?.containerId);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-7xl">
|
||||
<DialogHeader>
|
||||
@@ -96,6 +118,24 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
|
||||
id="terminal"
|
||||
containerId={containerId || "select-a-container"}
|
||||
/>
|
||||
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Are you sure you want to close the terminal?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
By clicking the confirm button, the terminal will be closed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>Confirm</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -58,14 +58,7 @@ export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
|
||||
}, [data]);
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-7xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>View Logs</DialogTitle>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
serverIp: z.string(),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
children?: React.ReactNode;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const UpdateServerIp = ({ children, serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data } = api.admin.one.useQuery();
|
||||
const { data: ip } = api.server.publicIp.useQuery();
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.admin.update.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
serverIp: data?.serverIp || "",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
serverIp: data.serverIp || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const setCurrentIp = () => {
|
||||
if (!ip) return;
|
||||
form.setValue("serverIp", ip);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
await mutateAsync({
|
||||
serverIp: data.serverIp,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Server IP Updated");
|
||||
await utils.admin.one.invalidate();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update the IP of the server");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Server IP</DialogTitle>
|
||||
<DialogDescription>Update the IP of the server</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-update-server-ip"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverIp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server IP</FormLabel>
|
||||
<FormControl className="flex gap-2">
|
||||
<div>
|
||||
<Input {...field} />
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={setCurrentIp}
|
||||
>
|
||||
<RefreshCw className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[11rem]"
|
||||
>
|
||||
<p>Set current public IP</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</FormControl>
|
||||
<pre>
|
||||
<FormMessage />
|
||||
</pre>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={isLoading}
|
||||
form="hook-form-update-server-ip"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -48,8 +48,8 @@ export const UpdateServer = () => {
|
||||
<li>Some bug that is blocking to use some features</li>
|
||||
</ul>
|
||||
<AlertBlock type="info">
|
||||
Please we recommend to see the latest version to see if there are
|
||||
any breaking changes before updating. Go to{" "}
|
||||
We recommend checking the latest version for any breaking changes
|
||||
before updating. Go to{" "}
|
||||
<Link
|
||||
href="https://github.com/Dokploy/dokploy/releases"
|
||||
target="_blank"
|
||||
@@ -87,7 +87,7 @@ export const UpdateServer = () => {
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Check updates
|
||||
Check Updates
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -161,29 +161,27 @@ export const GitlabIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
aria-label="gitlab"
|
||||
height="14"
|
||||
viewBox="0 0 24 22"
|
||||
width="14"
|
||||
className={cn("fill-white text-white", className)}
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn("text-white", className)}
|
||||
>
|
||||
<path
|
||||
d="M1.279 8.29L.044 12.294c-.117.367 0 .78.325 1.014l11.323 8.23-.009-.012-.03-.039L1.279 8.29zM22.992 13.308a.905.905 0 00.325-1.014L22.085 8.29 11.693 21.52l11.299-8.212z"
|
||||
fill="currentColor"
|
||||
d="m13.767 5.854-.02-.05L11.842.83a.5.5 0 0 0-.493-.312.5.5 0 0 0-.287.107.5.5 0 0 0-.169.257L9.607 4.819h-5.21L3.11.883A.5.5 0 0 0 2.162.83L.252 5.801l-.018.05a3.54 3.54 0 0 0 1.173 4.09l.007.005.017.012 2.903 2.174L5.77 13.22l.875.66a.59.59 0 0 0 .711 0l.875-.66 1.436-1.087 2.92-2.187.008-.006a3.54 3.54 0 0 0 1.172-4.085"
|
||||
fill="#E24329"
|
||||
/>
|
||||
<path
|
||||
d="M1.279 8.29l10.374 13.197.03.039.01-.006L22.085 8.29H1.28z"
|
||||
fill="currentColor"
|
||||
opacity="0.4"
|
||||
d="m13.767 5.854-.02-.05a6.4 6.4 0 0 0-2.562 1.152L7 10.12l2.666 2.015 2.92-2.187.007-.006a3.54 3.54 0 0 0 1.174-4.088"
|
||||
fill="#FC6D26"
|
||||
/>
|
||||
<path
|
||||
d="M15.982 8.29l-4.299 13.236-.004.011.014-.017L22.085 8.29h-6.103zM7.376 8.29H1.279l10.374 13.197L7.376 8.29z"
|
||||
fill="currentColor"
|
||||
opacity="0.6"
|
||||
d="m4.334 12.135 1.436 1.087.875.66a.59.59 0 0 0 .711 0l.875-.66 1.436-1.087S8.425 11.195 7 10.12c-1.425 1.075-2.666 2.015-2.666 2.015"
|
||||
fill="#FCA326"
|
||||
/>
|
||||
<path
|
||||
d="M18.582.308l-2.6 7.982h6.103L19.48.308c-.133-.41-.764-.41-.897 0zM1.279 8.29L3.88.308c.133-.41.764-.41.897 0l2.6 7.982H1.279z"
|
||||
fill="currentColor"
|
||||
opacity="0.4"
|
||||
d="M2.814 6.956A6.4 6.4 0 0 0 .253 5.8l-.02.05a3.54 3.54 0 0 0 1.174 4.09l.007.005.017.012 2.903 2.174L7 10.117z"
|
||||
fill="#FC6D26"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -200,7 +198,7 @@ export const GithubIcon = ({ className }: Props) => {
|
||||
>
|
||||
<path
|
||||
d="M7 .175c-3.872 0-7 3.128-7 7 0 3.084 2.013 5.71 4.79 6.65.35.066.482-.153.482-.328v-1.181c-1.947.415-2.363-.941-2.363-.941-.328-.81-.787-1.028-.787-1.028-.634-.438.044-.416.044-.416.7.044 1.071.722 1.071.722.635 1.072 1.641.766 2.035.59.066-.459.24-.765.437-.94-1.553-.175-3.193-.787-3.193-3.456 0-.766.262-1.378.721-1.881-.065-.175-.306-.897.066-1.86 0 0 .59-.197 1.925.722a6.754 6.754 0 0 1 1.75-.24c.59 0 1.203.087 1.75.24 1.335-.897 1.925-.722 1.925-.722.372.963.131 1.685.066 1.86.46.48.722 1.115.722 1.88 0 2.691-1.641 3.282-3.194 3.457.24.219.481.634.481 1.29v1.926c0 .197.131.415.481.328C11.988 12.884 14 10.259 14 7.175c0-3.872-3.128-7-7-7z"
|
||||
fill="#fff"
|
||||
fill="currentColor"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</svg>
|
||||
@@ -209,30 +207,34 @@ export const GithubIcon = ({ className }: Props) => {
|
||||
|
||||
export const BitbucketIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg height="14" viewBox="-2 -2 65 59" width="14" className={className}>
|
||||
<svg
|
||||
width="14"
|
||||
height="13"
|
||||
viewBox="0 0 14 13"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M.454 0a.448.448 0 0 0-.448.52l1.903 11.556a.61.61 0 0 0 .597.509h9.132a.45.45 0 0 0 .448-.377L13.994.525a.448.448 0 0 0-.448-.52zM8.47 8.352H5.555l-.79-4.121h4.411z"
|
||||
fill="#2684FF"
|
||||
/>
|
||||
<path
|
||||
d="M13.384 4.23H9.176L8.47 8.353H5.555L2.113 12.44c.11.095.248.147.393.148h9.134a.45.45 0 0 0 .448-.377z"
|
||||
fill="url(#a)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="bitbucket-:R7aq37rqjt7rrrmpjtuj7l9qjtsr:"
|
||||
x1="104.953%"
|
||||
x2="46.569%"
|
||||
y1="21.921%"
|
||||
y2="75.234%"
|
||||
id="a"
|
||||
x1="14.357"
|
||||
y1="5.383"
|
||||
x2="7.402"
|
||||
y2="10.814"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="7%" stopColor="currentColor" stopOpacity=".4" />
|
||||
<stop offset="100%" stopColor="currentColor" />
|
||||
<stop offset=".18" stopColor="#0052CC" />
|
||||
<stop offset="1" stopColor="#2684FF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M59.696 18.86h-18.77l-3.15 18.39h-13L9.426 55.47a2.71 2.71 0 001.75.66h40.74a2 2 0 002-1.68l5.78-35.59z"
|
||||
fill="url(#bitbucket-:R7aq37rqjt7rrrmpjtuj7l9qjtsr:)"
|
||||
fillRule="nonzero"
|
||||
transform="translate(-.026 .82)"
|
||||
/>
|
||||
<path
|
||||
d="M2 .82a2 2 0 00-2 2.32l8.49 51.54a2.7 2.7 0 00.91 1.61 2.71 2.71 0 001.75.66l15.76-18.88H24.7l-3.47-18.39h38.44l2.7-16.53a2 2 0 00-2-2.32L2 .82z"
|
||||
fill="currentColor"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export const StatusTooltip = ({ status, className }: Props) => {
|
||||
)}
|
||||
{status === "done" && (
|
||||
<div
|
||||
className={cn("size-3.5 rounded-full bg-primary", className)}
|
||||
className={cn("size-3.5 rounded-full bg-green-500", className)}
|
||||
/>
|
||||
)}
|
||||
{status === "running" && (
|
||||
|
||||
@@ -12,7 +12,7 @@ const buttonVariants = cva(
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/70",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
|
||||
@@ -61,7 +61,7 @@ const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<div ref={ref} {...props}>
|
||||
<Dot />
|
||||
</div>
|
||||
));
|
||||
|
||||
@@ -29,7 +29,7 @@ export const Secrets = (props: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between">
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between px-0">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{props.title}</CardTitle>
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
@@ -47,7 +47,7 @@ export const Secrets = (props: Props) => {
|
||||
)}
|
||||
</Toggle>
|
||||
</CardHeader>
|
||||
<CardContent className="w-full space-y-4">
|
||||
<CardContent className="w-full space-y-4 p-0">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={props.name}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -9,7 +11,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -17,7 +19,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
2
apps/dokploy/drizzle/0043_closed_naoko.sql
Normal file
2
apps/dokploy/drizzle/0043_closed_naoko.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "admin" ADD COLUMN "env" text DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "project" ADD COLUMN "env" text DEFAULT '' NOT NULL;
|
||||
1
apps/dokploy/drizzle/0044_sour_true_believers.sql
Normal file
1
apps/dokploy/drizzle/0044_sour_true_believers.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "admin" DROP COLUMN IF EXISTS "env";
|
||||
1
apps/dokploy/drizzle/0045_smiling_blur.sql
Normal file
1
apps/dokploy/drizzle/0045_smiling_blur.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "destination" ADD COLUMN "provider" text;
|
||||
1
apps/dokploy/drizzle/0046_purple_sleeper.sql
Normal file
1
apps/dokploy/drizzle/0046_purple_sleeper.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ADD COLUMN "registryUrl" text;
|
||||
1
apps/dokploy/drizzle/0047_tidy_revanche.sql
Normal file
1
apps/dokploy/drizzle/0047_tidy_revanche.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ADD COLUMN "herokuVersion" text;
|
||||
1
apps/dokploy/drizzle/0048_flat_expediter.sql
Normal file
1
apps/dokploy/drizzle/0048_flat_expediter.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ALTER COLUMN "herokuVersion" SET DEFAULT '24';
|
||||
53
apps/dokploy/drizzle/0049_dark_leopardon.sql
Normal file
53
apps/dokploy/drizzle/0049_dark_leopardon.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
ALTER TYPE "domainType" ADD VALUE 'preview';--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "preview_deployments" (
|
||||
"previewDeploymentId" text PRIMARY KEY NOT NULL,
|
||||
"branch" text NOT NULL,
|
||||
"pullRequestId" text NOT NULL,
|
||||
"pullRequestNumber" text NOT NULL,
|
||||
"pullRequestURL" text NOT NULL,
|
||||
"pullRequestTitle" text NOT NULL,
|
||||
"pullRequestCommentId" text NOT NULL,
|
||||
"previewStatus" "applicationStatus" DEFAULT 'idle' NOT NULL,
|
||||
"appName" text NOT NULL,
|
||||
"applicationId" text NOT NULL,
|
||||
"domainId" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"expiresAt" text,
|
||||
CONSTRAINT "preview_deployments_appName_unique" UNIQUE("appName")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewEnv" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewBuildArgs" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewWildcard" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewPort" integer DEFAULT 3000;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewHttps" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewPath" text DEFAULT '/';--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "certificateType" "certificateType" DEFAULT 'none' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewLimit" integer DEFAULT 3;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "isPreviewDeploymentsActive" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "domain" ADD COLUMN "previewDeploymentId" text;--> statement-breakpoint
|
||||
ALTER TABLE "deployment" ADD COLUMN "isPreviewDeployment" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "deployment" ADD COLUMN "previewDeploymentId" text;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "preview_deployments" ADD CONSTRAINT "preview_deployments_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "preview_deployments" ADD CONSTRAINT "preview_deployments_domainId_domain_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domain"("domainId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "domain" ADD CONSTRAINT "domain_previewDeploymentId_preview_deployments_previewDeploymentId_fk" FOREIGN KEY ("previewDeploymentId") REFERENCES "public"."preview_deployments"("previewDeploymentId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_previewDeploymentId_preview_deployments_previewDeploymentId_fk" FOREIGN KEY ("previewDeploymentId") REFERENCES "public"."preview_deployments"("previewDeploymentId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
3982
apps/dokploy/drizzle/meta/0043_snapshot.json
Normal file
3982
apps/dokploy/drizzle/meta/0043_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3975
apps/dokploy/drizzle/meta/0044_snapshot.json
Normal file
3975
apps/dokploy/drizzle/meta/0044_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3981
apps/dokploy/drizzle/meta/0045_snapshot.json
Normal file
3981
apps/dokploy/drizzle/meta/0045_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3987
apps/dokploy/drizzle/meta/0046_snapshot.json
Normal file
3987
apps/dokploy/drizzle/meta/0046_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3993
apps/dokploy/drizzle/meta/0047_snapshot.json
Normal file
3993
apps/dokploy/drizzle/meta/0047_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3994
apps/dokploy/drizzle/meta/0048_snapshot.json
Normal file
3994
apps/dokploy/drizzle/meta/0048_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4226
apps/dokploy/drizzle/meta/0049_snapshot.json
Normal file
4226
apps/dokploy/drizzle/meta/0049_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -302,6 +302,55 @@
|
||||
"when": 1729984439862,
|
||||
"tag": "0042_fancy_havok",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "6",
|
||||
"when": 1731873965888,
|
||||
"tag": "0043_closed_naoko",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "6",
|
||||
"when": 1731875539532,
|
||||
"tag": "0044_sour_true_believers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 45,
|
||||
"version": "6",
|
||||
"when": 1732644181718,
|
||||
"tag": "0045_smiling_blur",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "6",
|
||||
"when": 1732851191048,
|
||||
"tag": "0046_purple_sleeper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "6",
|
||||
"when": 1733599090582,
|
||||
"tag": "0047_tidy_revanche",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "6",
|
||||
"when": 1733599163710,
|
||||
"tag": "0048_flat_expediter",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 49,
|
||||
"version": "6",
|
||||
"when": 1733628762978,
|
||||
"tag": "0049_dark_leopardon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
16
apps/dokploy/lib/languages.ts
Normal file
16
apps/dokploy/lib/languages.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export enum Languages {
|
||||
English = "en",
|
||||
Polish = "pl",
|
||||
Russian = "ru",
|
||||
French = "fr",
|
||||
German = "de",
|
||||
ChineseTraditional = "zh-Hant",
|
||||
ChineseSimplified = "zh-Hans",
|
||||
Turkish = "tr",
|
||||
Kazakh = "kz",
|
||||
Persian = "fa",
|
||||
Korean = "ko",
|
||||
Portuguese = "pt-br",
|
||||
}
|
||||
|
||||
export type Language = keyof typeof Languages;
|
||||
@@ -4,3 +4,11 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export async function generateSHA256Hash(text: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(text);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
23
apps/dokploy/next-i18next.config.cjs
Normal file
23
apps/dokploy/next-i18next.config.cjs
Normal file
@@ -0,0 +1,23 @@
|
||||
/** @type {import('next-i18next').UserConfig} */
|
||||
module.exports = {
|
||||
fallbackLng: "en",
|
||||
keySeparator: false,
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: [
|
||||
"en",
|
||||
"pl",
|
||||
"ru",
|
||||
"fr",
|
||||
"de",
|
||||
"tr",
|
||||
"kz",
|
||||
"zh-Hant",
|
||||
"zh-Hans",
|
||||
"fa",
|
||||
"ko",
|
||||
"pt-br",
|
||||
],
|
||||
localeDetection: false,
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.10.7",
|
||||
"version": "v0.14.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -11,7 +11,8 @@
|
||||
"build-next": "next build",
|
||||
"setup": "tsx -r dotenv/config setup.ts && sleep 5 && pnpm run migration:run",
|
||||
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
||||
"dev": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
||||
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
||||
"dev-turbopack": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json",
|
||||
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
||||
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
|
||||
"migration:run": "tsx -r dotenv/config migration.ts",
|
||||
@@ -84,13 +85,16 @@
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-orm": "^0.30.8",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"i18next": "^23.16.4",
|
||||
"input-otp": "^1.2.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"js-yaml": "4.1.0",
|
||||
"lodash": "4.17.21",
|
||||
"lucia": "^3.0.1",
|
||||
"lucide-react": "^0.312.0",
|
||||
"nanoid": "3",
|
||||
"next": "^15.0.1",
|
||||
"next-i18next": "^15.3.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"node-pty": "1.0.0",
|
||||
"node-schedule": "2.1.1",
|
||||
@@ -100,6 +104,7 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.49.3",
|
||||
"react-i18next": "^15.1.0",
|
||||
"recharts": "^2.12.7",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.4.0",
|
||||
@@ -119,6 +124,7 @@
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.5",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/node": "^18.17.0",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import "@/styles/globals.css";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Languages } from "@/lib/languages";
|
||||
import { api } from "@/utils/api";
|
||||
import type { NextPage } from "next";
|
||||
import { appWithTranslation } from "next-i18next";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { AppProps } from "next/app";
|
||||
import { Inter } from "next/font/google";
|
||||
@@ -27,13 +29,14 @@ const MyApp = ({
|
||||
pageProps: { ...pageProps },
|
||||
}: AppPropsWithLayout) => {
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style jsx global>{`
|
||||
:root {
|
||||
--font-inter: ${inter.style.fontFamily};
|
||||
}
|
||||
`}</style>
|
||||
:root {
|
||||
--font-inter: ${inter.style.fontFamily};
|
||||
}
|
||||
`}</style>
|
||||
<Head>
|
||||
<title>Dokploy</title>
|
||||
</Head>
|
||||
@@ -59,4 +62,21 @@ const MyApp = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default api.withTRPC(MyApp);
|
||||
export default api.withTRPC(
|
||||
appWithTranslation(
|
||||
MyApp,
|
||||
// keep this in sync with next-i18next.config.js
|
||||
// if you want to know why don't just import the config file, this because next-i18next.config.js must be a CJS, but the rest of the code is ESM.
|
||||
// Add the config here is due to the issue: https://github.com/i18next/next-i18next/issues/2259
|
||||
// if one day every page is translated, we can safely remove this config.
|
||||
{
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: Object.values(Languages),
|
||||
localeDetection: false,
|
||||
},
|
||||
fallbackLng: "en",
|
||||
keySeparator: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,11 +3,19 @@ import { applications, compose, github } from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { myQueue } from "@/server/queues/queueSetup";
|
||||
import { deploy } from "@/server/utils/deploy";
|
||||
import { IS_CLOUD } from "@dokploy/server";
|
||||
import {
|
||||
createPreviewDeployment,
|
||||
type Domain,
|
||||
findPreviewDeploymentByApplicationId,
|
||||
findPreviewDeploymentsByPullRequestId,
|
||||
IS_CLOUD,
|
||||
removePreviewDeployment,
|
||||
} from "@dokploy/server";
|
||||
import { Webhooks } from "@octokit/webhooks";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { extractCommitMessage, extractHash } from "./[refreshToken]";
|
||||
import { generateRandomDomain } from "@/templates/utils";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
@@ -53,34 +61,183 @@ export default async function handler(
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.headers["x-github-event"] !== "push") {
|
||||
res.status(400).json({ message: "We only accept push events" });
|
||||
if (
|
||||
req.headers["x-github-event"] !== "push" &&
|
||||
req.headers["x-github-event"] !== "pull_request"
|
||||
) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ message: "We only accept push events or pull_request events" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const branchName = githubBody?.ref?.replace("refs/heads/", "");
|
||||
if (req.headers["x-github-event"] === "push") {
|
||||
try {
|
||||
const branchName = githubBody?.ref?.replace("refs/heads/", "");
|
||||
const repository = githubBody?.repository?.name;
|
||||
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
||||
const deploymentHash = extractHash(req.headers, req.body);
|
||||
const owner = githubBody?.repository?.owner?.name;
|
||||
|
||||
const apps = await db.query.applications.findMany({
|
||||
where: and(
|
||||
eq(applications.sourceType, "github"),
|
||||
eq(applications.autoDeploy, true),
|
||||
eq(applications.branch, branchName),
|
||||
eq(applications.repository, repository),
|
||||
eq(applications.owner, owner),
|
||||
),
|
||||
});
|
||||
|
||||
for (const app of apps) {
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: app.applicationId as string,
|
||||
titleLog: deploymentTitle,
|
||||
descriptionLog: `Hash: ${deploymentHash}`,
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
server: !!app.serverId,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && app.serverId) {
|
||||
jobData.serverId = app.serverId;
|
||||
await deploy(jobData);
|
||||
return true;
|
||||
}
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const composeApps = await db.query.compose.findMany({
|
||||
where: and(
|
||||
eq(compose.sourceType, "github"),
|
||||
eq(compose.autoDeploy, true),
|
||||
eq(compose.branch, branchName),
|
||||
eq(compose.repository, repository),
|
||||
eq(compose.owner, owner),
|
||||
),
|
||||
});
|
||||
|
||||
for (const composeApp of composeApps) {
|
||||
const jobData: DeploymentJob = {
|
||||
composeId: composeApp.composeId as string,
|
||||
titleLog: deploymentTitle,
|
||||
type: "deploy",
|
||||
applicationType: "compose",
|
||||
descriptionLog: `Hash: ${deploymentHash}`,
|
||||
server: !!composeApp.serverId,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && composeApp.serverId) {
|
||||
jobData.serverId = composeApp.serverId;
|
||||
await deploy(jobData);
|
||||
return true;
|
||||
}
|
||||
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const totalApps = apps.length + composeApps.length;
|
||||
const emptyApps = totalApps === 0;
|
||||
|
||||
if (emptyApps) {
|
||||
res.status(200).json({ message: "No apps to deploy" });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ message: `Deployed ${totalApps} apps` });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: "Error To Deploy Application", error });
|
||||
}
|
||||
} else if (req.headers["x-github-event"] === "pull_request") {
|
||||
const prId = githubBody?.pull_request?.id;
|
||||
|
||||
if (githubBody?.action === "closed") {
|
||||
const previewDeploymentResult =
|
||||
await findPreviewDeploymentsByPullRequestId(prId);
|
||||
|
||||
if (previewDeploymentResult.length > 0) {
|
||||
for (const previewDeployment of previewDeploymentResult) {
|
||||
try {
|
||||
await removePreviewDeployment(
|
||||
previewDeployment.previewDeploymentId,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
res.status(200).json({ message: "Preview Deployment Closed" });
|
||||
return;
|
||||
}
|
||||
// opened or synchronize or reopened
|
||||
const repository = githubBody?.repository?.name;
|
||||
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
||||
const deploymentHash = extractHash(req.headers, req.body);
|
||||
const deploymentHash = githubBody?.pull_request?.head?.sha;
|
||||
const branch = githubBody?.pull_request?.base?.ref;
|
||||
const owner = githubBody?.repository?.owner?.login;
|
||||
|
||||
const apps = await db.query.applications.findMany({
|
||||
where: and(
|
||||
eq(applications.sourceType, "github"),
|
||||
eq(applications.autoDeploy, true),
|
||||
eq(applications.branch, branchName),
|
||||
eq(applications.repository, repository),
|
||||
eq(applications.branch, branch),
|
||||
eq(applications.isPreviewDeploymentsActive, true),
|
||||
eq(applications.owner, owner),
|
||||
),
|
||||
with: {
|
||||
previewDeployments: true,
|
||||
},
|
||||
});
|
||||
|
||||
const prBranch = githubBody?.pull_request?.head?.ref;
|
||||
|
||||
const prNumber = githubBody?.pull_request?.number;
|
||||
const prTitle = githubBody?.pull_request?.title;
|
||||
const prURL = githubBody?.pull_request?.html_url;
|
||||
|
||||
for (const app of apps) {
|
||||
const previewLimit = app?.previewLimit || 0;
|
||||
if (app?.previewDeployments?.length > previewLimit) {
|
||||
continue;
|
||||
}
|
||||
const previewDeploymentResult =
|
||||
await findPreviewDeploymentByApplicationId(app.applicationId, prId);
|
||||
|
||||
let previewDeploymentId =
|
||||
previewDeploymentResult?.previewDeploymentId || "";
|
||||
|
||||
if (!previewDeploymentResult) {
|
||||
const previewDeployment = await createPreviewDeployment({
|
||||
applicationId: app.applicationId as string,
|
||||
branch: prBranch,
|
||||
pullRequestId: prId,
|
||||
pullRequestNumber: prNumber,
|
||||
pullRequestTitle: prTitle,
|
||||
pullRequestURL: prURL,
|
||||
});
|
||||
previewDeploymentId = previewDeployment.previewDeploymentId;
|
||||
}
|
||||
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: app.applicationId as string,
|
||||
titleLog: deploymentTitle,
|
||||
titleLog: "Preview Deployment",
|
||||
descriptionLog: `Hash: ${deploymentHash}`,
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
applicationType: "application-preview",
|
||||
server: !!app.serverId,
|
||||
previewDeploymentId,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && app.serverId) {
|
||||
@@ -97,50 +254,8 @@ export default async function handler(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const composeApps = await db.query.compose.findMany({
|
||||
where: and(
|
||||
eq(compose.sourceType, "github"),
|
||||
eq(compose.autoDeploy, true),
|
||||
eq(compose.branch, branchName),
|
||||
eq(compose.repository, repository),
|
||||
),
|
||||
});
|
||||
|
||||
for (const composeApp of composeApps) {
|
||||
const jobData: DeploymentJob = {
|
||||
composeId: composeApp.composeId as string,
|
||||
titleLog: deploymentTitle,
|
||||
type: "deploy",
|
||||
applicationType: "compose",
|
||||
descriptionLog: `Hash: ${deploymentHash}`,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && composeApp.serverId) {
|
||||
jobData.serverId = composeApp.serverId;
|
||||
await deploy(jobData);
|
||||
return true;
|
||||
}
|
||||
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const totalApps = apps.length + composeApps.length;
|
||||
const emptyApps = totalApps === 0;
|
||||
|
||||
if (emptyApps) {
|
||||
res.status(200).json({ message: "No apps to deploy" });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ message: `Deployed ${totalApps} apps` });
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: "Error To Deploy Application", error });
|
||||
return res.status(200).json({ message: "Apps Deployed" });
|
||||
}
|
||||
|
||||
return res.status(400).json({ message: "No Actions matched" });
|
||||
}
|
||||
|
||||
@@ -88,7 +88,6 @@ export default async function handler(
|
||||
.update(admins)
|
||||
.set({
|
||||
stripeSubscriptionId: newSubscription.id,
|
||||
serversQuantity: 0,
|
||||
stripeCustomerId: newSubscription.customer as string,
|
||||
})
|
||||
.where(eq(admins.stripeCustomerId, newSubscription.customer as string))
|
||||
@@ -121,12 +120,6 @@ export default async function handler(
|
||||
}
|
||||
case "customer.subscription.updated": {
|
||||
const newSubscription = event.data.object as Stripe.Subscription;
|
||||
await db
|
||||
.update(admins)
|
||||
.set({
|
||||
serversQuantity: newSubscription?.items?.data?.[0]?.quantity ?? 0,
|
||||
})
|
||||
.where(eq(admins.stripeCustomerId, newSubscription.customer as string));
|
||||
|
||||
const admin = await findAdminByStripeCustomerId(
|
||||
newSubscription.customer as string,
|
||||
@@ -136,8 +129,27 @@ export default async function handler(
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
}
|
||||
|
||||
const newServersQuantity = admin.serversQuantity;
|
||||
await updateServersBasedOnQuantity(admin.adminId, newServersQuantity);
|
||||
if (newSubscription.status === "active") {
|
||||
await db
|
||||
.update(admins)
|
||||
.set({
|
||||
serversQuantity: newSubscription?.items?.data?.[0]?.quantity ?? 0,
|
||||
})
|
||||
.where(
|
||||
eq(admins.stripeCustomerId, newSubscription.customer as string),
|
||||
);
|
||||
|
||||
const newServersQuantity = admin.serversQuantity;
|
||||
await updateServersBasedOnQuantity(admin.adminId, newServersQuantity);
|
||||
} else {
|
||||
await disableServers(admin.adminId);
|
||||
await db
|
||||
.update(admins)
|
||||
.set({ serversQuantity: 0 })
|
||||
.where(
|
||||
eq(admins.stripeCustomerId, newSubscription.customer as string),
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -148,6 +160,13 @@ export default async function handler(
|
||||
newInvoice.subscription as string,
|
||||
);
|
||||
|
||||
if (suscription.status !== "active") {
|
||||
console.log(
|
||||
`Skipping invoice.payment_succeeded for subscription ${suscription.id} with status ${suscription.status}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(admins)
|
||||
.set({
|
||||
@@ -168,22 +187,29 @@ export default async function handler(
|
||||
}
|
||||
case "invoice.payment_failed": {
|
||||
const newInvoice = event.data.object as Stripe.Invoice;
|
||||
await db
|
||||
.update(admins)
|
||||
.set({
|
||||
serversQuantity: 0,
|
||||
})
|
||||
.where(eq(admins.stripeCustomerId, newInvoice.customer as string));
|
||||
|
||||
const admin = await findAdminByStripeCustomerId(
|
||||
newInvoice.customer as string,
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
newInvoice.subscription as string,
|
||||
);
|
||||
|
||||
if (!admin) {
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
if (subscription.status !== "active") {
|
||||
const admin = await findAdminByStripeCustomerId(
|
||||
newInvoice.customer as string,
|
||||
);
|
||||
|
||||
if (!admin) {
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
}
|
||||
await db
|
||||
.update(admins)
|
||||
.set({
|
||||
serversQuantity: 0,
|
||||
})
|
||||
.where(eq(admins.stripeCustomerId, newInvoice.customer as string));
|
||||
|
||||
await disableServers(admin.adminId);
|
||||
}
|
||||
|
||||
await disableServers(admin.adminId);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AddApplication } from "@/components/dashboard/project/add-application";
|
||||
import { AddCompose } from "@/components/dashboard/project/add-compose";
|
||||
import { AddDatabase } from "@/components/dashboard/project/add-database";
|
||||
import { AddTemplate } from "@/components/dashboard/project/add-template";
|
||||
import { ProjectEnviroment } from "@/components/dashboard/projects/project-enviroment";
|
||||
import {
|
||||
MariadbIcon,
|
||||
MongodbIcon,
|
||||
@@ -198,27 +199,35 @@ const Project = (
|
||||
</div>
|
||||
|
||||
{(auth?.rol === "admin" || user?.canCreateServices) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create Service
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[200px] space-y-2" align="end">
|
||||
<DropdownMenuLabel className="text-sm font-normal ">
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<AddApplication
|
||||
projectId={projectId}
|
||||
projectName={data?.name}
|
||||
/>
|
||||
<AddDatabase projectId={projectId} projectName={data?.name} />
|
||||
<AddCompose projectId={projectId} projectName={data?.name} />
|
||||
<AddTemplate projectId={projectId} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<ProjectEnviroment projectId={projectId}>
|
||||
<Button variant="outline">Project Enviroment</Button>
|
||||
</ProjectEnviroment>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create Service
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[200px] space-y-2"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuLabel className="text-sm font-normal ">
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<AddApplication
|
||||
projectId={projectId}
|
||||
projectName={data?.name}
|
||||
/>
|
||||
<AddDatabase projectId={projectId} projectName={data?.name} />
|
||||
<AddCompose projectId={projectId} projectName={data?.name} />
|
||||
<AddTemplate projectId={projectId} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ShowDomains } from "@/components/dashboard/application/domains/show-dom
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show";
|
||||
import { ShowGeneralApplication } from "@/components/dashboard/application/general/show";
|
||||
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
|
||||
import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
|
||||
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
||||
import { DockerMonitoring } from "@/components/dashboard/monitoring/docker/show";
|
||||
import { ProjectLayout } from "@/components/layouts/project-layout";
|
||||
@@ -51,7 +52,8 @@ type TabState =
|
||||
| "advanced"
|
||||
| "deployments"
|
||||
| "domains"
|
||||
| "monitoring";
|
||||
| "monitoring"
|
||||
| "preview-deployments";
|
||||
|
||||
const Service = (
|
||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||
@@ -191,8 +193,8 @@ const Service = (
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||
data?.serverId ? "md:grid-cols-6" : "md:grid-cols-7",
|
||||
"flex gap-8 justify-start max-xl:overflow-x-scroll overflow-y-hidden",
|
||||
data?.serverId ? "md:grid-cols-7" : "md:grid-cols-8",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
@@ -202,6 +204,9 @@ const Service = (
|
||||
)}
|
||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="preview-deployments">
|
||||
Preview Deployments
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="domains">Domains</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -244,6 +249,11 @@ const Service = (
|
||||
<ShowDeployments applicationId={applicationId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="preview-deployments" className="w-full">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowPreviewDeployments applicationId={applicationId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="domains" className="w-full">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowDomains applicationId={applicationId} />
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { ShowProjects } from "@/components/dashboard/projects/show";
|
||||
import { ShowWelcomeDokploy } from "@/components/dashboard/settings/billing/show-welcome-dokploy";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
import type React from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
|
||||
const ShowWelcomeDokploy = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/settings/billing/show-welcome-dokploy").then(
|
||||
(mod) => mod.ShowWelcomeDokploy,
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const Dashboard = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
return (
|
||||
<>
|
||||
<ShowWelcomeDokploy />
|
||||
{isCloud && <ShowWelcomeDokploy />}
|
||||
|
||||
<ShowProjects />
|
||||
</>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user