fix: resolved merge conflicts with fork/canary

This commit is contained in:
Jason Parks
2025-03-16 03:02:15 -06:00
502 changed files with 18288 additions and 16475 deletions

View File

@@ -165,86 +165,8 @@ Thank you for your contribution!
## Templates
To add a new template, go to `templates` folder and create a new folder with the name of the template.
To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file.
Let's take the example of `plausible` template.
1. create a folder in `templates/plausible`
2. create a `docker-compose.yml` file inside the folder with the content of compose.
3. create a `index.ts` file inside the folder with the following code as base:
4. When creating a pull request, please provide a video of the template working in action.
```typescript
// EXAMPLE
import {
generateBase64,
generateHash,
generateRandomDomain,
type Template,
type Schema,
type DomainSchema,
} from "../utils";
export function generate(schema: Schema): Template {
// do your stuff here, like create a new domain, generate random passwords, mounts.
const mainServiceHash = generateHash(schema.projectName);
const mainDomain = generateRandomDomain(schema);
const secretBase = generateBase64(64);
const toptKeyBase = generateBase64(32);
const domains: DomainSchema[] = [
{
host: mainDomain,
port: 8000,
serviceName: "plausible",
},
];
const envs = [
`BASE_URL=http://${mainDomain}`,
`SECRET_KEY_BASE=${secretBase}`,
`TOTP_VAULT_KEY=${toptKeyBase}`,
`HASH=${mainServiceHash}`,
];
const mounts: Template["mounts"] = [
{
filePath: "./clickhouse/clickhouse-config.xml",
content: "some content......",
},
];
return {
envs,
mounts,
domains,
};
}
```
4. Now you need to add the information about the template to the `templates/templates.ts` is a object with the following properties:
**Make sure the id of the template is the same as the folder name and don't have any spaces, only slugified names and lowercase.**
```typescript
{
id: "plausible",
name: "Plausible",
version: "v2.1.0",
description:
"Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.",
logo: "plausible.svg", // we defined the name and the extension of the logo
links: {
github: "https://github.com/plausible/plausible",
website: "https://plausible.io/",
docs: "https://plausible.io/docs",
},
tags: ["analytics"],
load: () => import("./plausible/index").then((m) => m.generate),
},
```
5. Add the logo or image of the template to `public/templates/plausible.svg`
### Recommendations

View File

@@ -1,242 +0,0 @@
# Contributing
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
We have a few guidelines to follow when contributing to this project:
- [Commit Convention](#commit-convention)
- [Setup](#setup)
- [Development](#development)
- [Build](#build)
- [Pull Request](#pull-request)
## Commit Convention
Before you craete a Pull Request, please make sure your commit message follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
### Commit Message Format
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
```
#### Type
Must be one of the following:
* **feat**: A new feature
* **fix**: A bug fix
* **docs**: Documentation only changes
* **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
* **refactor**: A code change that neither fixes a bug nor adds a feature
* **perf**: A code change that improves performance
* **test**: Adding missing tests or correcting existing tests
* **build**: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
* **ci**: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
* **chore**: Other changes that don't modify `src` or `test` files
* **revert**: Reverts a previous commit
Example:
```
feat: add new feature
```
## Setup
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
```bash
git clone https://github.com/dokploy/dokploy.git
cd dokploy
pnpm install
cp .env.example .env
```
## Development
Is required to have **Docker** installed on your machine.
### Setup
Run the command that will spin up all the required services and files.
```bash
pnpm run setup
```
Now run the development server.
```bash
pnpm run dev
```
Go to http://localhost:3000 to see the development server
## Build
```bash
pnpm run build
```
## Docker
To build the docker image
```bash
pnpm run docker:build
```
To push the docker image
```bash
pnpm run docker:push
```
## Password Reset
In the case you lost your password, you can reset it using the following command
```bash
pnpm run reset-password
```
If you want to test the webhooks on development mode using localtunnel, make sure to install `localtunnel`
```bash
bunx lt --port 3000
```
If you run into permission issues of docker run the following command
```bash
sudo chown -R USERNAME dokploy or sudo chown -R $(whoami) ~/.docker
```
## Application deploy
In case you want to deploy the application on your machine and you selected nixpacks or buildpacks, you need to install first.
```bash
# Install Nixpacks
curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh
```
```bash
# Install Buildpacks
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
```
## Pull Request
- The `main` branch is the source of truth and should always reflect the latest stable release.
- Create a new branch for each feature or bug fix.
- Make sure to add tests for your changes.
- Make sure to update the documentation for any changes Go to the [docs.dokploy.com](https://docs.dokploy.com) website to see the changes.
- When creating a pull request, please provide a clear and concise description of the changes made.
- If you include a video or screenshot, would be awesome so we can see the changes in action.
- If your pull request fixes an open issue, please reference the issue in the pull request description.
- Once your pull request is merged, you will be automatically added as a contributor to the project.
Thank you for your contribution!
## Templates
To add a new template, go to `templates` folder and create a new folder with the name of the template.
Let's take the example of `plausible` template.
1. create a folder in `templates/plausible`
2. create a `docker-compose.yml` file inside the folder with the content of compose.
3. create a `index.ts` file inside the folder with the following code as base:
4. When creating a pull request, please provide a video of the template working in action.
```typescript
// EXAMPLE
import {
generateHash,
generateRandomDomain,
type Template,
type Schema,
} from "../utils";
export function generate(schema: Schema): Template {
// do your stuff here, like create a new domain, generate random passwords, mounts.
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const secretBase = generateBase64(64);
const toptKeyBase = generateBase64(32);
const envs = [
// If you want to show a domain in the UI, please add the prefix _HOST at the end of the variable name.
`PLAUSIBLE_HOST=${randomDomain}`,
"PLAUSIBLE_PORT=8000",
`BASE_URL=http://${randomDomain}`,
`SECRET_KEY_BASE=${secretBase}`,
`TOTP_VAULT_KEY=${toptKeyBase}`,
`HASH=${mainServiceHash}`,
];
const mounts: Template["mounts"] = [
{
mountPath: "./clickhouse/clickhouse-config.xml",
content: `some content......`,
},
];
return {
envs,
mounts,
};
}
```
4. Now you need to add the information about the template to the `templates/templates.ts` is a object with the following properties:
**Make sure the id of the template is the same as the folder name and don't have any spaces, only slugified names and lowercase.**
```typescript
{
id: "plausible",
name: "Plausible",
version: "v2.1.0",
description:
"Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.",
logo: "plausible.svg", // we defined the name and the extension of the logo
links: {
github: "https://github.com/plausible/plausible",
website: "https://plausible.io/",
docs: "https://plausible.io/docs",
},
tags: ["analytics"],
load: () => import("./plausible/index").then((m) => m.generate),
},
```
5. Add the logo or image of the template to `public/templates/plausible.svg`
### Recomendations
- Use the same name of the folder as the id of the template.
- The logo should be in the public folder.
- If you want to show a domain in the UI, please add the prefix _HOST at the end of the variable name.
- Test first on a vps or a server to make sure the template works.

View File

@@ -9,6 +9,7 @@ describe("createDomainLabels", () => {
port: 8080,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "",

View File

@@ -27,6 +27,7 @@ if (typeof window === "undefined") {
const baseApp: ApplicationNested = {
applicationId: "",
herokuVersion: "",
watchPaths: [],
applicationStatus: "done",
appName: "",
autoDeploy: true,
@@ -37,6 +38,7 @@ const baseApp: ApplicationNested = {
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewCertificateType: "none",
previewCustomCertResolver: null,
previewEnv: null,
previewHttps: false,
previewPath: "/",

View File

@@ -0,0 +1,425 @@
import { describe, expect, it } from "vitest";
import type { CompleteTemplate } from "@dokploy/server/templates/processors";
import { processTemplate } from "@dokploy/server/templates/processors";
import type { Schema } from "@dokploy/server/templates";
describe("processTemplate", () => {
// Mock schema for testing
const mockSchema: Schema = {
projectName: "test",
serverIp: "127.0.0.1",
};
describe("variables processing", () => {
it("should process basic variables with utility functions", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
main_domain: "${domain}",
secret_base: "${base64:64}",
totp_key: "${base64:32}",
password: "${password:32}",
hash: "${hash:16}",
},
config: {
domains: [],
env: {},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(0);
expect(result.domains).toHaveLength(0);
expect(result.mounts).toHaveLength(0);
});
it("should allow referencing variables in other variables", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
main_domain: "${domain}",
api_domain: "api.${main_domain}",
},
config: {
domains: [],
env: {},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(0);
expect(result.domains).toHaveLength(0);
expect(result.mounts).toHaveLength(0);
});
});
describe("domains processing", () => {
it("should process domains with explicit host", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
main_domain: "${domain}",
},
config: {
domains: [
{
serviceName: "plausible",
port: 8000,
host: "${main_domain}",
},
],
env: {},
},
};
const result = processTemplate(template, mockSchema);
expect(result.domains).toHaveLength(1);
const domain = result.domains[0];
expect(domain).toBeDefined();
if (!domain) return;
expect(domain).toMatchObject({
serviceName: "plausible",
port: 8000,
});
expect(domain.host).toBeDefined();
expect(domain.host).toContain(mockSchema.projectName);
});
it("should generate random domain if host is not specified", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [
{
serviceName: "plausible",
port: 8000,
},
],
env: {},
},
};
const result = processTemplate(template, mockSchema);
expect(result.domains).toHaveLength(1);
const domain = result.domains[0];
expect(domain).toBeDefined();
if (!domain || !domain.host) return;
expect(domain.host).toBeDefined();
expect(domain.host).toContain(mockSchema.projectName);
});
it("should allow using ${domain} directly in host", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [
{
serviceName: "plausible",
port: 8000,
host: "${domain}",
},
],
env: {},
},
};
const result = processTemplate(template, mockSchema);
expect(result.domains).toHaveLength(1);
const domain = result.domains[0];
expect(domain).toBeDefined();
if (!domain || !domain.host) return;
expect(domain.host).toBeDefined();
expect(domain.host).toContain(mockSchema.projectName);
});
});
describe("environment variables processing", () => {
it("should process env vars with variable references", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
main_domain: "${domain}",
secret_base: "${base64:64}",
},
config: {
domains: [],
env: {
BASE_URL: "http://${main_domain}",
SECRET_KEY_BASE: "${secret_base}",
},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(2);
const baseUrl = result.envs.find((env: string) =>
env.startsWith("BASE_URL="),
);
const secretKey = result.envs.find((env: string) =>
env.startsWith("SECRET_KEY_BASE="),
);
expect(baseUrl).toBeDefined();
expect(secretKey).toBeDefined();
if (!baseUrl || !secretKey) return;
expect(baseUrl).toContain(mockSchema.projectName);
const base64Value = secretKey.split("=")[1];
expect(base64Value).toBeDefined();
if (!base64Value) return;
expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
expect(base64Value.length).toBeGreaterThanOrEqual(86);
expect(base64Value.length).toBeLessThanOrEqual(88);
});
it("should process env vars when provided as an array", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: [
'CLOUDFLARE_TUNNEL_TOKEN="<INSERT TOKEN>"',
'ANOTHER_VAR="some value"',
"DOMAIN=${domain}",
],
mounts: [],
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(3);
// Should preserve exact format for static values
expect(result.envs[0]).toBe('CLOUDFLARE_TUNNEL_TOKEN="<INSERT TOKEN>"');
expect(result.envs[1]).toBe('ANOTHER_VAR="some value"');
// Should process variables in array items
expect(result.envs[2]).toContain(mockSchema.projectName);
});
it("should allow using utility functions directly in env vars", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {
RANDOM_DOMAIN: "${domain}",
SECRET_KEY: "${base64:32}",
},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(2);
const randomDomainEnv = result.envs.find((env: string) =>
env.startsWith("RANDOM_DOMAIN="),
);
const secretKeyEnv = result.envs.find((env: string) =>
env.startsWith("SECRET_KEY="),
);
expect(randomDomainEnv).toBeDefined();
expect(secretKeyEnv).toBeDefined();
if (!randomDomainEnv || !secretKeyEnv) return;
expect(randomDomainEnv).toContain(mockSchema.projectName);
const base64Value = secretKeyEnv.split("=")[1];
expect(base64Value).toBeDefined();
if (!base64Value) return;
expect(base64Value).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
expect(base64Value.length).toBeGreaterThanOrEqual(42);
expect(base64Value.length).toBeLessThanOrEqual(44);
});
});
describe("mounts processing", () => {
it("should process mounts with variable references", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
config_path: "/etc/config",
secret_key: "${base64:32}",
},
config: {
domains: [],
env: {},
mounts: [
{
filePath: "${config_path}/config.xml",
content: "secret_key=${secret_key}",
},
],
},
};
const result = processTemplate(template, mockSchema);
expect(result.mounts).toHaveLength(1);
const mount = result.mounts[0];
expect(mount).toBeDefined();
if (!mount) return;
expect(mount.filePath).toContain("/etc/config");
expect(mount.content).toMatch(/secret_key=[A-Za-z0-9+/]{32}/);
});
it("should allow using utility functions directly in mount content", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {},
mounts: [
{
filePath: "/config/secrets.txt",
content: "random_domain=${domain}\nsecret=${base64:32}",
},
],
},
};
const result = processTemplate(template, mockSchema);
expect(result.mounts).toHaveLength(1);
const mount = result.mounts[0];
expect(mount).toBeDefined();
if (!mount) return;
expect(mount.content).toContain(mockSchema.projectName);
expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{32}/);
});
});
describe("complex template processing", () => {
it("should process a complete template with all features", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
main_domain: "${domain}",
secret_base: "${base64:64}",
totp_key: "${base64:32}",
},
config: {
domains: [
{
serviceName: "plausible",
port: 8000,
host: "${main_domain}",
},
{
serviceName: "api",
port: 3000,
host: "api.${main_domain}",
},
],
env: {
BASE_URL: "http://${main_domain}",
SECRET_KEY_BASE: "${secret_base}",
TOTP_VAULT_KEY: "${totp_key}",
},
mounts: [
{
filePath: "/config/app.conf",
content: `
domain=\${main_domain}
secret=\${secret_base}
totp=\${totp_key}
`,
},
],
},
};
const result = processTemplate(template, mockSchema);
// Check domains
expect(result.domains).toHaveLength(2);
const [domain1, domain2] = result.domains;
expect(domain1).toBeDefined();
expect(domain2).toBeDefined();
if (!domain1 || !domain2) return;
expect(domain1.host).toBeDefined();
expect(domain1.host).toContain(mockSchema.projectName);
expect(domain2.host).toContain("api.");
expect(domain2.host).toContain(mockSchema.projectName);
// Check env vars
expect(result.envs).toHaveLength(3);
const baseUrl = result.envs.find((env: string) =>
env.startsWith("BASE_URL="),
);
const secretKey = result.envs.find((env: string) =>
env.startsWith("SECRET_KEY_BASE="),
);
const totpKey = result.envs.find((env: string) =>
env.startsWith("TOTP_VAULT_KEY="),
);
expect(baseUrl).toBeDefined();
expect(secretKey).toBeDefined();
expect(totpKey).toBeDefined();
if (!baseUrl || !secretKey || !totpKey) return;
expect(baseUrl).toContain(mockSchema.projectName);
// Check base64 lengths and format
const secretKeyValue = secretKey.split("=")[1];
const totpKeyValue = totpKey.split("=")[1];
expect(secretKeyValue).toBeDefined();
expect(totpKeyValue).toBeDefined();
if (!secretKeyValue || !totpKeyValue) return;
expect(secretKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
expect(secretKeyValue.length).toBeGreaterThanOrEqual(86);
expect(secretKeyValue.length).toBeLessThanOrEqual(88);
expect(totpKeyValue).toMatch(/^[A-Za-z0-9+/]+={0,2}$/);
expect(totpKeyValue.length).toBeGreaterThanOrEqual(42);
expect(totpKeyValue.length).toBeLessThanOrEqual(44);
// Check mounts
expect(result.mounts).toHaveLength(1);
const mount = result.mounts[0];
expect(mount).toBeDefined();
if (!mount) return;
expect(mount.content).toContain(mockSchema.projectName);
expect(mount.content).toMatch(/secret=[A-Za-z0-9+/]{86,88}/);
expect(mount.content).toMatch(/totp=[A-Za-z0-9+/]{42,44}/);
});
});
describe("Should populate envs, domains and mounts in the case we didn't used any variable", () => {
it("should populate envs, domains and mounts in the case we didn't used any variable", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [
{
serviceName: "plausible",
port: 8000,
host: "${hash}",
},
],
env: {
BASE_URL: "http://${domain}",
SECRET_KEY_BASE: "${password:32}",
TOTP_VAULT_KEY: "${base64:128}",
},
mounts: [
{
filePath: "/config/secrets.txt",
content: "random_domain=${domain}\nsecret=${password:32}",
},
],
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(3);
expect(result.domains).toHaveLength(1);
expect(result.mounts).toHaveLength(1);
});
});
});

View File

@@ -47,7 +47,7 @@ const baseAdmin: User = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
enableLogRotation: false,
logCleanupCron: null,
serversQuantity: 0,
stripeCustomerId: "",
stripeSubscriptionId: "",

View File

@@ -14,6 +14,7 @@ const baseApp: ApplicationNested = {
branch: null,
dockerBuildStage: "",
registryUrl: "",
watchPaths: [],
buildArgs: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
@@ -23,6 +24,7 @@ const baseApp: ApplicationNested = {
previewPath: "/",
previewPort: 3000,
previewLimit: 0,
previewCustomCertResolver: null,
previewWildcard: "",
project: {
env: "",
@@ -103,6 +105,7 @@ const baseDomain: Domain = {
port: null,
serviceName: "",
composeId: "",
customCertResolver: null,
domainType: "application",
uniqueConfigKey: 1,
previewDeploymentId: "",

View File

@@ -0,0 +1,347 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Code2, Globe2, HardDrive } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const ImportSchema = z.object({
base64: z.string(),
});
type ImportType = z.infer<typeof ImportSchema>;
interface Props {
composeId: string;
}
export const ShowImport = ({ composeId }: Props) => {
const [showModal, setShowModal] = useState(false);
const [showMountContent, setShowMountContent] = useState(false);
const [selectedMount, setSelectedMount] = useState<{
filePath: string;
content: string;
} | null>(null);
const [templateInfo, setTemplateInfo] = useState<{
compose: string;
template: {
domains: Array<{
serviceName: string;
port: number;
path?: string;
host?: string;
}>;
envs: string[];
mounts: Array<{
filePath: string;
content: string;
}>;
};
} | null>(null);
const utils = api.useUtils();
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } =
api.compose.processTemplate.useMutation();
const {
mutateAsync: importTemplate,
isLoading: isImporting,
isSuccess: isImportSuccess,
} = api.compose.import.useMutation();
const form = useForm<ImportType>({
defaultValues: {
base64: "",
},
resolver: zodResolver(ImportSchema),
});
useEffect(() => {
form.reset({
base64: "",
});
}, [isImportSuccess]);
const onSubmit = async () => {
const base64 = form.getValues("base64");
if (!base64) {
toast.error("Please enter a base64 template");
return;
}
try {
await importTemplate({
composeId,
base64,
});
toast.success("Template imported successfully");
await utils.compose.one.invalidate({
composeId,
});
setShowModal(false);
} catch (_error) {
toast.error("Error importing template");
}
};
const handleLoadTemplate = async () => {
const base64 = form.getValues("base64");
if (!base64) {
toast.error("Please enter a base64 template");
return;
}
try {
const result = await processTemplate({
composeId,
base64,
});
setTemplateInfo(result);
setShowModal(true);
} catch (_error) {
toast.error("Error processing template");
}
};
const handleShowMountContent = (mount: {
filePath: string;
content: string;
}) => {
setSelectedMount(mount);
setShowMountContent(true);
};
return (
<>
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Import</CardTitle>
<CardDescription>Import your Template configuration</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="warning">
Warning: Importing a template will remove all existing environment
variables, mounts, and domains from this service.
</AlertBlock>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="base64"
render={({ field }) => (
<FormItem>
<FormLabel>Configuration (Base64)</FormLabel>
<FormControl>
<Textarea
placeholder="Enter your Base64 configuration here..."
className="font-mono min-h-[200px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
className="w-fit"
variant="outline"
isLoading={isLoadingTemplate}
onClick={handleLoadTemplate}
>
Load
</Button>
</div>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="max-h-[80vh] max-w-[50vw] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
</DialogTitle>
<DialogDescription className="space-y-2">
<p>Review the template information before importing</p>
<AlertBlock type="warning">
Warning: This will remove all existing environment
variables, mounts, and domains from this service.
</AlertBlock>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">
Docker Compose
</h3>
</div>
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
<Separator />
{templateInfo?.template.domains &&
templateInfo.template.domains.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Domains</h3>
</div>
<div className="grid grid-cols-1 gap-3">
{templateInfo.template.domains.map(
(domain, index) => (
<div
key={index}
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
>
<div className="font-medium">
{domain.serviceName}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div>Port: {domain.port}</div>
{domain.host && (
<div>Host: {domain.host}</div>
)}
{domain.path && (
<div>Path: {domain.path}</div>
)}
</div>
</div>
),
)}
</div>
</div>
)}
{templateInfo?.template.envs &&
templateInfo.template.envs.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">
Environment Variables
</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.envs.map((env, index) => (
<div
key={index}
className="rounded-lg border bg-card p-2 font-mono text-sm"
>
{env}
</div>
))}
</div>
</div>
)}
{templateInfo?.template.mounts &&
templateInfo.template.mounts.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Mounts</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.mounts.map(
(mount, index) => (
<div
key={index}
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
onClick={() => handleShowMountContent(mount)}
>
{mount.filePath}
</div>
),
)}
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => setShowModal(false)}
>
Cancel
</Button>
<Button
isLoading={isImporting}
type="submit"
onClick={form.handleSubmit(onSubmit)}
className="w-fit"
>
Import
</Button>
</div>
</DialogContent>
</Dialog>
</form>
</Form>
</CardContent>
</Card>
<Dialog open={showMountContent} onOpenChange={setShowMountContent}>
<DialogContent className="max-w-[50vw]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{selectedMount?.filePath}
</DialogTitle>
<DialogDescription>Mount File Content</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[25vh] pr-4">
<CodeEditor
language="yaml"
value={selectedMount?.content || ""}
className="font-mono"
readOnly
/>
</ScrollArea>
<div className="flex justify-end gap-2 pt-4">
<Button onClick={() => setShowMountContent(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -85,8 +85,20 @@ export const AddDomain = ({
const form = useForm<Domain>({
resolver: zodResolver(domain),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
},
mode: "onChange",
});
const certificateType = form.watch("certificateType");
const https = form.watch("https");
useEffect(() => {
if (data) {
form.reset({
@@ -94,13 +106,29 @@ export const AddDomain = ({
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
});
}
if (!domainId) {
form.reset({});
form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
});
}
}, [form, form.reset, data, isLoading]);
}, [form, data, isLoading, domainId]);
// Separate effect for handling custom cert resolver validation
useEffect(() => {
if (certificateType === "custom") {
form.trigger("customCertResolver");
}
}, [certificateType, form]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
@@ -256,34 +284,73 @@ export const AddDomain = ({
)}
/>
{form.getValues().https && (
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
{https && (
<>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
if (value !== "custom") {
form.setValue(
"customCertResolver",
undefined,
);
}
}}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
{certificateType === "custom" && (
<FormField
control={form.control}
name="customCertResolver"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Custom Certificate Resolver</FormLabel>
<FormControl>
<Input
className="w-full"
placeholder="Enter your custom certificate resolver"
{...field}
value={field.value || ""}
onChange={(e) => {
field.onChange(e);
form.trigger("customCertResolver");
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
/>
</>
)}
</div>
</div>

View File

@@ -71,15 +71,19 @@ export const ShowEnvironment = ({ id, type }: Props) => {
resolver: zodResolver(addEnvironmentSchema),
});
// Watch form value
const currentEnvironment = form.watch("environment");
const hasChanges = currentEnvironment !== (data?.env || "");
useEffect(() => {
if (data) {
form.reset({
environment: data.env || "",
});
}
}, [form.reset, data, form]);
}, [data, form]);
const onSubmit = async (data: EnvironmentSchema) => {
const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
mongoId: id || "",
postgresId: id || "",
@@ -87,7 +91,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
mysqlId: id || "",
mariadbId: id || "",
composeId: id || "",
env: data.environment,
env: formData.environment,
})
.then(async () => {
toast.success("Environments Added");
@@ -98,6 +102,12 @@ export const ShowEnvironment = ({ id, type }: Props) => {
});
};
const handleCancel = () => {
form.reset({
environment: data?.env || "",
});
};
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
@@ -106,6 +116,11 @@ export const ShowEnvironment = ({ id, type }: Props) => {
<CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription>
You can add environment variables to your resource.
{hasChanges && (
<span className="text-yellow-500 ml-2">
(You have unsaved changes)
</span>
)}
</CardDescription>
</div>
@@ -132,8 +147,8 @@ export const ShowEnvironment = ({ id, type }: Props) => {
control={form.control}
name="environment"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<FormItem>
<FormControl className="">
<CodeEditor
style={
{
@@ -142,21 +157,35 @@ export const ShowEnvironment = ({ id, type }: Props) => {
}
language="properties"
disabled={isEnvVisible}
className="font-mono"
wrapperClassName="compose-file-editor"
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit">
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button
type="button"
variant="outline"
onClick={handleCancel}
>
Cancel
</Button>
)}
<Button
isLoading={isLoading}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>

View File

@@ -7,6 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { useEffect } from "react";
const addEnvironmentSchema = z.object({
env: z.string(),
@@ -34,16 +35,32 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const form = useForm<EnvironmentSchema>({
defaultValues: {
env: data?.env || "",
buildArgs: data?.buildArgs || "",
env: "",
buildArgs: "",
},
resolver: zodResolver(addEnvironmentSchema),
});
const onSubmit = async (data: EnvironmentSchema) => {
// Watch form values
const currentEnv = form.watch("env");
const currentBuildArgs = form.watch("buildArgs");
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "");
useEffect(() => {
if (data) {
form.reset({
env: data.env || "",
buildArgs: data.buildArgs || "",
});
}
}, [data, form]);
const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
env: data.env,
buildArgs: data.buildArgs,
env: formData.env,
buildArgs: formData.buildArgs,
applicationId,
})
.then(async () => {
@@ -55,6 +72,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
});
};
const handleCancel = () => {
form.reset({
env: data?.env || "",
buildArgs: data?.buildArgs || "",
});
};
return (
<Card className="bg-background px-6 pb-6">
<Form {...form}>
@@ -65,7 +89,16 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
<Secrets
name="env"
title="Environment Settings"
description="You can add environment variables to your resource."
description={
<span>
You can add environment variables to your resource.
{hasChanges && (
<span className="text-yellow-500 ml-2">
(You have unsaved changes)
</span>
)}
</span>
}
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
/>
{data?.buildType === "dockerfile" && (
@@ -89,8 +122,18 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
placeholder="NPM_TOKEN=xyz"
/>
)}
<div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit">
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
)}
<Button
isLoading={isLoading}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
Save
</Button>
</div>

View File

@@ -29,14 +29,23 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const BitbucketProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
@@ -48,6 +57,7 @@ const BitbucketProviderSchema = z.object({
.required(),
branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
});
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
@@ -73,6 +83,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
},
bitbucketId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(BitbucketProviderSchema),
});
@@ -118,6 +129,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
},
buildPath: data.bitbucketBuildPath || "/",
bitbucketId: data.bitbucketId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -130,6 +142,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
bitbucketBuildPath: data.buildPath,
bitbucketId: data.bitbucketId,
applicationId,
watchPaths: data.watchPaths || [],
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -195,7 +208,20 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Repository</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<BitbucketIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -363,6 +389,84 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button

View File

@@ -115,7 +115,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
<Input placeholder="Username" autoComplete="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -130,7 +130,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="Password" {...field} type="password" />
<Input placeholder="Password" autoComplete="one-time-code" {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -17,23 +17,33 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon } from "lucide-react";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import { useRouter } from "next/router";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { GitIcon } from "@/components/icons/data-tools-icons";
const GitProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z.string().min(1, "Branch required"),
buildPath: z.string().min(1, "Build Path required"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
});
type GitProvider = z.infer<typeof GitProviderSchema>;
@@ -56,6 +66,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
buildPath: "/",
repositoryURL: "",
sshKey: undefined,
watchPaths: [],
},
resolver: zodResolver(GitProviderSchema),
});
@@ -67,6 +78,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
branch: data.customGitBranch || "",
buildPath: data.customGitBuildPath || "/",
repositoryURL: data.customGitUrl || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -78,6 +90,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
customGitUrl: values.repositoryURL,
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
applicationId,
watchPaths: values.watchPaths || [],
})
.then(async () => {
toast.success("Git Provider Saved");
@@ -102,9 +115,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel>Repository URL</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -160,19 +186,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</Button>
)}
</div>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-4">
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="buildPath"
@@ -186,6 +215,85 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered. This
will work only when manual webhook is setup.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end">

View File

@@ -28,14 +28,23 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import Link from "next/link";
import { GithubIcon } from "@/components/icons/data-tools-icons";
const GithubProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
@@ -47,6 +56,7 @@ const GithubProviderSchema = z.object({
.required(),
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
});
type GithubProvider = z.infer<typeof GithubProviderSchema>;
@@ -113,6 +123,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
},
buildPath: data.buildPath || "/",
githubId: data.githubId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -125,6 +136,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
owner: data.repository.owner,
buildPath: data.buildPath,
githubId: data.githubId,
watchPaths: data.watchPaths || [],
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -187,7 +199,20 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Repository</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GithubIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -350,7 +375,85 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}
}}
/>
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const input = document.querySelector(
'input[placeholder*="Enter a path"]',
) as HTMLInputElement;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}}
>
<Plus className="size-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}

View File

@@ -29,14 +29,23 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import Link from "next/link";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
const GitlabProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
@@ -50,6 +59,7 @@ const GitlabProviderSchema = z.object({
.required(),
branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
});
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
@@ -124,6 +134,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
},
buildPath: data.gitlabBuildPath || "/",
gitlabId: data.gitlabId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -138,6 +149,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
applicationId,
gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace,
watchPaths: data.watchPaths || [],
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -203,7 +215,20 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Repository</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitlabIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -375,7 +400,85 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormControl>
<Input placeholder="/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}
}}
/>
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const input = document.querySelector(
'input[placeholder*="Enter a path"]',
) as HTMLInputElement;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}}
>
<Plus className="size-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}

View File

@@ -4,8 +4,22 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api";
import { Ban, CheckCircle2, Hammer, RefreshCcw, Terminal } from "lucide-react";
import {
Ban,
CheckCircle2,
Hammer,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -41,128 +55,188 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<DialogAction
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await deploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying application");
});
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Application reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading application");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
>
Rebuild
<Hammer className="size-4" />
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Start Application"
description="Are you sure you want to start this application?"
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await start({
await deploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application started successfully");
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error starting application");
toast.error("Error deploying application");
});
}}
>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Downloads the source code and performs a complete build
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Application"
description="Are you sure you want to stop this application?"
title="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await stop({
await reload({
applicationId: applicationId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Application stopped successfully");
toast.success("Application reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping application");
toast.error("Error reloading application");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
)}
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Rebuild
<Hammer className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Only rebuilds the application without downloading new
code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Application"
description="Are you sure you want to start this application?"
type="default"
onClick={async () => {
await start({
applicationId: applicationId,
})
.then(() => {
toast.success("Application started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting application");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the application (requires a previous successful
build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Application"
description="Are you sure you want to stop this application?"
onClick={async () => {
await stop({
applicationId: applicationId,
})
.then(() => {
toast.success("Application stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping application");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running application</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}

View File

@@ -94,6 +94,7 @@ export const AddPreviewDomain = ({
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
customCertResolver: data?.customCertResolver || undefined,
});
}

View File

@@ -35,16 +35,30 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
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"]),
});
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", "custom"]),
previewCustomCertResolver: z.string().optional(),
})
.superRefine((input, ctx) => {
if (
input.previewCertificateType === "custom" &&
!input.previewCustomCertResolver
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["previewCustomCertResolver"],
message: "Required",
});
}
});
type Schema = z.infer<typeof schema>;
@@ -90,6 +104,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: data.previewHttps || false,
previewPath: data.previewPath || "/",
previewCertificateType: data.previewCertificateType || "none",
previewCustomCertResolver: data.previewCustomCertResolver || "",
});
}
}, [data]);
@@ -105,6 +120,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: formData.previewHttps,
previewPath: formData.previewPath,
previewCertificateType: formData.previewCertificateType,
previewCustomCertResolver: formData.previewCustomCertResolver,
})
.then(() => {
toast.success("Preview Deployments settings updated");
@@ -184,10 +200,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
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>
@@ -238,6 +250,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -245,6 +258,25 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
)}
/>
)}
{form.watch("previewCertificateType") === "custom" && (
<FormField
control={form.control}
name="previewCustomCertResolver"
render={({ field }) => (
<FormItem>
<FormLabel>Certificate Provider</FormLabel>
<FormControl>
<Input
placeholder="my-custom-resolver"
{...field}
/>
</FormControl>
<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">

View File

@@ -121,7 +121,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Tesla" {...field} />
<Input placeholder="Vandelay Industries" {...field} />
</FormControl>
<FormMessage />

View File

@@ -104,6 +104,15 @@ export const AddDomainCompose = ({
const form = useForm<Domain>({
resolver: zodResolver(domainCompose),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
},
});
const https = form.watch("https");
@@ -116,11 +125,21 @@ export const AddDomainCompose = ({
path: data?.path || undefined,
port: data?.port || undefined,
serviceName: data?.serviceName || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
});
}
if (!domainId) {
form.reset({});
form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
});
}
}, [form, form.reset, data, isLoading]);
@@ -393,33 +412,55 @@ export const AddDomainCompose = ({
/>
{https && (
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.getValues().certificateType === "custom" && (
<FormField
control={form.control}
name="customCertResolver"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Custom Certificate Resolver</FormLabel>
<FormControl>
<Input
placeholder="Enter your custom certificate resolver"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
/>
</>
)}
</div>
</div>

View File

@@ -1,8 +1,15 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api";
import { Ban, CheckCircle2, Hammer, Terminal } from "lucide-react";
import { Ban, CheckCircle2, Hammer, HelpCircle, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -27,103 +34,159 @@ export const ComposeActions = ({ composeId }: Props) => {
api.compose.stop.useMutation();
return (
<div className="flex flex-row gap-4 w-full flex-wrap ">
<DialogAction
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
type="default"
onClick={async () => {
await deploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button variant="default" isLoading={data?.composeStatus === "running"}>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Rebuild Compose"
description="Are you sure you want to rebuild this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding compose");
});
}}
>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
>
Rebuild
<Hammer className="size-4" />
</Button>
</DialogAction>
{data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Start Compose"
description="Are you sure you want to start this compose?"
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
type="default"
onClick={async () => {
await start({
await deploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose started successfully");
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error starting compose");
toast.error("Error deploying compose");
});
}}
>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
<Button
variant="default"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads the source code and performs a complete build</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Compose"
description="Are you sure you want to stop this compose?"
title="Rebuild Compose"
description="Are you sure you want to rebuild this compose?"
type="default"
onClick={async () => {
await stop({
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose stopped successfully");
toast.success("Compose rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping compose");
toast.error("Error rebuilding compose");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5"
>
Rebuild
<Hammer className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Only rebuilds the compose without downloading new code</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<DialogAction
title="Start Compose"
description="Are you sure you want to start this compose?"
type="default"
onClick={async () => {
await start({
composeId: composeId,
})
.then(() => {
toast.success("Compose started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting compose");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the compose (requires a previous successful build)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Compose"
description="Are you sure you want to stop this compose?"
onClick={async () => {
await stop({
composeId: composeId,
})
.then(() => {
toast.success("Compose stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping compose");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running compose</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}

View File

@@ -97,6 +97,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
<div className="flex flex-col gap-4 w-full outline-none focus:outline-none overflow-auto">
<CodeEditor
// disabled
language="yaml"
value={field.value}
className="font-mono"
wrapperClassName="compose-file-editor"

View File

@@ -29,14 +29,23 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const BitbucketProviderSchema = z.object({
composePath: z.string().min(1),
@@ -48,6 +57,7 @@ const BitbucketProviderSchema = z.object({
.required(),
branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
});
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
@@ -73,6 +83,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
},
bitbucketId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(BitbucketProviderSchema),
});
@@ -118,6 +129,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
},
composePath: data.composePath,
bitbucketId: data.bitbucketId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -132,6 +144,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "bitbucket",
composeStatus: "idle",
watchPaths: data.watchPaths,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -197,7 +210,20 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Repository</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<BitbucketIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -365,6 +391,84 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Form,
@@ -17,14 +18,22 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon } from "lucide-react";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { GitIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GitProviderSchema = z.object({
composePath: z.string().min(1),
@@ -33,6 +42,7 @@ const GitProviderSchema = z.object({
}),
branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
});
type GitProvider = z.infer<typeof GitProviderSchema>;
@@ -54,6 +64,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
repositoryURL: "",
composePath: "./docker-compose.yml",
sshKey: undefined,
watchPaths: [],
},
resolver: zodResolver(GitProviderSchema),
});
@@ -65,6 +76,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
branch: data.customGitBranch || "",
repositoryURL: data.customGitUrl || "",
composePath: data.composePath,
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -77,6 +89,8 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "git",
composePath: values.composePath,
composeStatus: "idle",
watchPaths: values.watchPaths || [],
})
.then(async () => {
toast.success("Git Provider Saved");
@@ -101,11 +115,22 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
name="repositoryURL"
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row justify-between">
Repository URL
</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="git@bitbucket.org" {...field} />
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -191,6 +216,85 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered. This
will work only when manual webhook is setup.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex flex-row justify-end">

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -28,14 +29,22 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GithubProviderSchema = z.object({
composePath: z.string().min(1),
@@ -47,6 +56,7 @@ const GithubProviderSchema = z.object({
.required(),
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
});
type GithubProvider = z.infer<typeof GithubProviderSchema>;
@@ -71,6 +81,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
},
githubId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(GithubProviderSchema),
});
@@ -113,6 +124,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
},
composePath: data.composePath,
githubId: data.githubId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -127,6 +139,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
githubId: data.githubId,
sourceType: "github",
composeStatus: "idle",
watchPaths: data.watchPaths,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -183,13 +196,25 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Repository</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://github.com/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GithubIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -357,6 +382,84 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button

View File

@@ -29,14 +29,23 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GitlabProviderSchema = z.object({
composePath: z.string().min(1),
@@ -50,6 +59,7 @@ const GitlabProviderSchema = z.object({
.required(),
branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
});
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
@@ -76,6 +86,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
},
gitlabId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(GitlabProviderSchema),
});
@@ -124,6 +135,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
},
composePath: data.composePath,
gitlabId: data.gitlabId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -140,6 +152,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabPathNamespace: data.repository.gitlabPathNamespace,
sourceType: "gitlab",
composeStatus: "idle",
watchPaths: data.watchPaths,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -199,13 +212,25 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Repository</FormLabel>
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitlabIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -382,6 +407,84 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button

View File

@@ -147,9 +147,9 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Enable Randomize ({data?.appName})</FormLabel>
<FormLabel>Enable Isolated Deployment ({data?.appName})</FormLabel>
<FormDescription>
Enable randomize to the compose file.
Enable isolated deployment to the compose file.
</FormDescription>
</div>
<FormControl>

View File

@@ -121,7 +121,7 @@ export const UpdateCompose = ({ composeId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Tesla" {...field} />
<Input placeholder="Vandelay Industries" {...field} />
</FormControl>
<FormMessage />

View File

@@ -54,6 +54,7 @@ const AddPostgresBackup1Schema = z.object({
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
});
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
@@ -77,6 +78,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
},
resolver: zodResolver(AddPostgresBackup1Schema),
});
@@ -88,6 +90,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
@@ -117,6 +120,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
keepLatestCount: data.keepLatestCount,
databaseType,
...getDatabaseId,
})
@@ -265,7 +269,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
<Input placeholder={"dokploy/"} {...field} />
</FormControl>
<FormDescription>
Use if you want to storage in a specific path of your
Use if you want to back up in a specific path of your
destination/bucket
</FormDescription>
@@ -274,6 +278,24 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
);
}}
/>
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Keep the latest</FormLabel>
<FormControl>
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} />
</FormControl>
<FormDescription>
Optional. If provided, only keeps the latest N backups in the cloud.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="enabled"

View File

@@ -20,12 +20,14 @@ import { toast } from "sonner";
import type { ServiceType } from "../../application/advanced/show-resources";
import { AddBackup } from "./add-backup";
import { UpdateBackup } from "./update-backup";
import { useState } from "react";
interface Props {
id: string;
type: Exclude<ServiceType, "application" | "redis">;
}
export const ShowBackups = ({ id, type }: Props) => {
const [activeManualBackup, setActiveManualBackup] = useState<string | undefined>();
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
@@ -106,7 +108,7 @@ export const ShowBackups = ({ id, type }: Props) => {
{postgres?.backups.map((backup) => (
<div key={backup.backupId}>
<div className="flex w-full flex-col md:flex-row md:items-center justify-between gap-4 md:gap-10 border rounded-lg p-4">
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-5 flex-col gap-8">
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 flex-col gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Destination</span>
<span className="text-sm text-muted-foreground">
@@ -137,6 +139,12 @@ export const ShowBackups = ({ id, type }: Props) => {
{backup.enabled ? "Yes" : "No"}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Keep Latest</span>
<span className="text-sm text-muted-foreground">
{backup.keepLatestCount || 'All'}
</span>
</div>
</div>
<div className="flex flex-row gap-4">
<TooltipProvider delayDuration={0}>
@@ -145,8 +153,9 @@ export const ShowBackups = ({ id, type }: Props) => {
<Button
type="button"
variant="ghost"
isLoading={isManualBackup}
isLoading={isManualBackup && activeManualBackup === backup.backupId}
onClick={async () => {
setActiveManualBackup(backup.backupId);
await manualBackup({
backupId: backup.backupId as string,
})
@@ -160,6 +169,7 @@ export const ShowBackups = ({ id, type }: Props) => {
"Error creating the manual backup",
);
});
setActiveManualBackup(undefined);
}}
>
<Play className="size-5 text-muted-foreground" />

View File

@@ -47,6 +47,7 @@ const UpdateBackupSchema = z.object({
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
});
type UpdateBackup = z.infer<typeof UpdateBackupSchema>;
@@ -78,6 +79,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
},
resolver: zodResolver(UpdateBackupSchema),
});
@@ -90,6 +92,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
enabled: backup.enabled || false,
prefix: backup.prefix,
schedule: backup.schedule,
keepLatestCount: backup.keepLatestCount ? Number(backup.keepLatestCount) : undefined,
});
}
}, [form, form.reset, backup]);
@@ -102,6 +105,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
keepLatestCount: data.keepLatestCount as number | null,
})
.then(async () => {
toast.success("Backup Updated");
@@ -253,7 +257,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
<Input placeholder={"dokploy/"} {...field} />
</FormControl>
<FormDescription>
Use if you want to storage in a specific path of your
Use if you want to back up in a specific path of your
destination/bucket
</FormDescription>
@@ -262,6 +266,24 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
);
}}
/>
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Keep the latest</FormLabel>
<FormControl>
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} />
</FormControl>
<FormDescription>
Optional. If provided, only keeps the latest N backups in the cloud.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="enabled"

View File

@@ -1,5 +1,6 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import { AlertBlock } from "@/components/shared/alert-block";
import {
Card,
CardContent,
@@ -23,6 +24,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import Link from "next/link";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
@@ -106,6 +108,17 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">
{!getIp && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link href="/dashboard/settings" className="text-primary">
{data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to fix the database url connection.
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

@@ -2,8 +2,21 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
import {
Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
@@ -65,92 +78,150 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<DialogAction
title="Deploy Mariadb"
description="Are you sure you want to deploy this mariadb?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Mariadb"
description="Are you sure you want to reload this mariadb?"
type="default"
onClick={async () => {
await reload({
mariadbId: mariadbId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mariadb reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mariadb");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Start Mariadb"
description="Are you sure you want to start this mariadb?"
title="Deploy Mariadb"
description="Are you sure you want to deploy this mariadb?"
type="default"
onClick={async () => {
await start({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mariadb");
});
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mariadb"
description="Are you sure you want to stop this mariadb?"
title="Reload Mariadb"
description="Are you sure you want to reload this mariadb?"
type="default"
onClick={async () => {
await stop({
await reload({
mariadbId: mariadbId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mariadb stopped successfully");
toast.success("Mariadb reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mariadb");
toast.error("Error reloading Mariadb");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload
<RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MariaDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Mariadb"
description="Are you sure you want to start this mariadb?"
type="default"
onClick={async () => {
await start({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mariadb");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MariaDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mariadb"
description="Are you sure you want to stop this mariadb?"
onClick={async () => {
await stop({
mariadbId: mariadbId,
})
.then(() => {
toast.success("Mariadb stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mariadb");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}

View File

@@ -119,7 +119,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Tesla" {...field} />
<Input placeholder="Vandelay Industries" {...field} />
</FormControl>
<FormMessage />

View File

@@ -1,5 +1,6 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import { AlertBlock } from "@/components/shared/alert-block";
import {
Card,
CardContent,
@@ -23,6 +24,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import Link from "next/link";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
@@ -106,6 +108,17 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">
{!getIp && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link href="/dashboard/settings" className="text-primary">
{data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to fix the database url connection.
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

@@ -2,8 +2,21 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
import {
Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
@@ -64,93 +77,150 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<DialogAction
title="Deploy Mongo"
description="Are you sure you want to deploy this mongo?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Mongo"
description="Are you sure you want to reload this mongo?"
type="default"
onClick={async () => {
await reload({
mongoId: mongoId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mongo reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mongo");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Start Mongo"
description="Are you sure you want to start this mongo?"
title="Deploy Mongo"
description="Are you sure you want to deploy this mongo?"
type="default"
onClick={async () => {
await start({
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Mongo"
description="Are you sure you want to reload this mongo?"
type="default"
onClick={async () => {
await reload({
mongoId: mongoId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mongo started successfully");
toast.success("Mongo reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mongo");
toast.error("Error reloading Mongo");
});
}}
>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload
<RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MongoDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mongo"
description="Are you sure you want to stop this mongo?"
type="default"
onClick={async () => {
await stop({
mongoId: mongoId,
})
.then(() => {
toast.success("Mongo stopped successfully");
refetch();
{data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Mongo"
description="Are you sure you want to start this mongo?"
type="default"
onClick={async () => {
await start({
mongoId: mongoId,
})
.catch(() => {
toast.error("Error stopping Mongo");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
</Button>
</DialogAction>
)}
.then(() => {
toast.success("Mongo started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mongo");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MongoDB database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mongo"
description="Are you sure you want to stop this mongo?"
onClick={async () => {
await stop({
mongoId: mongoId,
})
.then(() => {
toast.success("Mongo stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mongo");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}

View File

@@ -121,7 +121,7 @@ export const UpdateMongo = ({ mongoId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Tesla" {...field} />
<Input placeholder="Vandelay Industries" {...field} />
</FormControl>
<FormMessage />

View File

@@ -218,7 +218,7 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
Used: {currentData.cpu.value}%
Used: {currentData.cpu.value}
</span>
<Progress value={currentData.cpu.value} className="w-[100%]" />
<DockerCpuChart acummulativeData={acummulativeData.cpu} />

View File

@@ -1,5 +1,6 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import { AlertBlock } from "@/components/shared/alert-block";
import {
Card,
CardContent,
@@ -23,6 +24,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import Link from "next/link";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
@@ -106,6 +108,17 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">
{!getIp && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link href="/dashboard/settings" className="text-primary">
{data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to fix the database url connection.
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

@@ -2,8 +2,21 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
import {
Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
@@ -62,93 +75,150 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<DialogAction
title="Deploy Mysql"
description="Are you sure you want to deploy this mysql?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Mysql"
description="Are you sure you want to reload this mysql?"
type="default"
onClick={async () => {
await reload({
mysqlId: mysqlId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mysql reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mysql");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Start Mysql"
description="Are you sure you want to start this mysql?"
title="Deploy Mysql"
description="Are you sure you want to deploy this mysql?"
type="default"
onClick={async () => {
await start({
mysqlId: mysqlId,
})
.then(() => {
toast.success("Mysql started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mysql");
});
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mysql"
description="Are you sure you want to stop this mysql?"
title="Reload Mysql"
description="Are you sure you want to reload this mysql?"
type="default"
onClick={async () => {
await stop({
await reload({
mysqlId: mysqlId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mysql stopped successfully");
toast.success("Mysql reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mysql");
toast.error("Error reloading Mysql");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload
<RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MySQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Mysql"
description="Are you sure you want to start this mysql?"
type="default"
onClick={async () => {
await start({
mysqlId: mysqlId,
})
.then(() => {
toast.success("Mysql started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Mysql");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the MySQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Mysql"
description="Are you sure you want to stop this mysql?"
onClick={async () => {
await stop({
mysqlId: mysqlId,
})
.then(() => {
toast.success("Mysql stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Mysql");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}

View File

@@ -119,7 +119,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Tesla" {...field} />
<Input placeholder="Vandelay Industries" {...field} />
</FormControl>
<FormMessage />

View File

@@ -1,5 +1,6 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import { AlertBlock } from "@/components/shared/alert-block";
import {
Card,
CardContent,
@@ -23,6 +24,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import Link from "next/link";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
@@ -108,6 +110,17 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">
{!getIp && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link href="/dashboard/settings" className="text-primary">
{data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to fix the database url connection.
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

@@ -2,12 +2,26 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
import {
Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props {
postgresId: string;
}
@@ -57,122 +71,179 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
);
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader className="pb-4">
<CardTitle className="text-xl">General</CardTitle>
</CardHeader>
<CardContent className="flex gap-4">
<DialogAction
title="Deploy Postgres"
description="Are you sure you want to deploy this postgres?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Postgres"
description="Are you sure you want to reload this postgres?"
type="default"
onClick={async () => {
await reload({
postgresId: postgresId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Postgres reloaded successfully");
<>
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<DialogAction
title="Deploy Postgres"
description="Are you sure you want to deploy this postgres?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
})
.catch(() => {
toast.error("Error reloading Postgres");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Postgres"
description="Are you sure you want to start this postgres?"
type="default"
onClick={async () => {
await start({
postgresId: postgresId,
})
.then(() => {
toast.success("Postgres started successfully");
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the PostgreSQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Reload Postgres"
description="Are you sure you want to reload this postgres?"
type="default"
onClick={async () => {
await reload({
postgresId: postgresId,
appName: data?.appName || "",
})
.catch(() => {
toast.error("Error starting Postgres");
});
}}
.then(() => {
toast.success("Postgres reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Postgres");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload
<RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the PostgreSQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
{data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Postgres"
description="Are you sure you want to start this postgres?"
type="default"
onClick={async () => {
await start({
postgresId: postgresId,
})
.then(() => {
toast.success("Postgres started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Postgres");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the PostgreSQL database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Postgres"
description="Are you sure you want to stop this postgres?"
onClick={async () => {
await stop({
postgresId: postgresId,
})
.then(() => {
toast.success("Postgres stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Postgres");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running PostgreSQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Postgres"
description="Are you sure you want to stop this postgres?"
onClick={async () => {
await stop({
postgresId: postgresId,
})
.then(() => {
toast.success("Postgres stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Postgres");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
</Button>
</DialogAction>
)}
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
</CardContent>
</Card>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>
</div>
</DockerTerminalModal>
</CardContent>
</Card>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>
</div>
</>
);
};

View File

@@ -121,7 +121,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Tesla" {...field} />
<Input placeholder="Vandelay Industries" {...field} />
</FormControl>
<FormMessage />

View File

@@ -494,7 +494,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
<Input
type="password"
placeholder="******************"
autoComplete="off"
autoComplete="one-time-code"
{...field}
/>
</FormControl>

View File

@@ -1,3 +1,4 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import {
AlertDialog,
@@ -57,32 +58,67 @@ import {
BookText,
CheckIcon,
ChevronsUpDown,
Github,
Globe,
HelpCircle,
LayoutGrid,
List,
Loader2,
PuzzleIcon,
SearchIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useState, useEffect } from "react";
import { toast } from "sonner";
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
interface Props {
projectId: string;
baseUrl?: string;
}
export const AddTemplate = ({ projectId }: Props) => {
export const AddTemplate = ({ projectId, baseUrl }: Props) => {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const { data } = api.compose.templates.useQuery();
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
// Try to get from props first, then localStorage
if (baseUrl) return baseUrl;
if (typeof window !== "undefined") {
return localStorage.getItem(TEMPLATE_BASE_URL_KEY) || undefined;
}
return undefined;
});
// Save to localStorage when customBaseUrl changes
useEffect(() => {
if (customBaseUrl) {
localStorage.setItem(TEMPLATE_BASE_URL_KEY, customBaseUrl);
} else {
localStorage.removeItem(TEMPLATE_BASE_URL_KEY);
}
}, [customBaseUrl]);
const {
data,
isLoading: isLoadingTemplates,
error: errorTemplates,
isError: isErrorTemplates,
} = api.compose.templates.useQuery(
{ baseUrl: customBaseUrl },
{
enabled: open,
},
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: tags, isLoading: isLoadingTags } =
api.compose.getTags.useQuery();
const { data: tags, isLoading: isLoadingTags } = api.compose.getTags.useQuery(
{ baseUrl: customBaseUrl },
{
enabled: open,
},
);
const utils = api.useUtils();
const [serverId, setServerId] = useState<string | undefined>(undefined);
@@ -129,6 +165,14 @@ export const AddTemplate = ({ projectId }: Props) => {
className="w-full sm:w-[200px]"
value={query}
/>
<Input
placeholder="Base URL (optional)"
onChange={(e) =>
setCustomBaseUrl(e.target.value || undefined)
}
className="w-full sm:w-[300px]"
value={customBaseUrl || ""}
/>
<Popover modal={true}>
<PopoverTrigger asChild>
<Button
@@ -232,7 +276,20 @@ export const AddTemplate = ({ projectId }: Props) => {
</AlertBlock>
)}
{templates.length === 0 ? (
{isErrorTemplates && (
<AlertBlock type="error" className="mb-4">
{errorTemplates?.message}
</AlertBlock>
)}
{isLoadingTemplates ? (
<div className="flex justify-center items-center w-full h-full flex-row gap-4">
<Loader2 className="size-8 text-muted-foreground animate-spin min-h-[60vh]" />
<div className="text-lg font-medium text-muted-foreground">
Loading templates...
</div>
</div>
) : templates.length === 0 ? (
<div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
<SearchIcon className="text-muted-foreground size-6" />
<div className="text-xl font-medium text-muted-foreground">
@@ -248,9 +305,9 @@ export const AddTemplate = ({ projectId }: Props) => {
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6",
)}
>
{templates?.map((template, index) => (
{templates?.map((template) => (
<div
key={`template-${index}`}
key={template.id}
className={cn(
"flex flex-col border rounded-lg overflow-hidden relative",
viewMode === "icon" && "h-[200px]",
@@ -260,7 +317,6 @@ export const AddTemplate = ({ projectId }: Props) => {
<Badge className="absolute top-2 right-2" variant="blue">
{template.version}
</Badge>
{/* Template Header */}
<div
className={cn(
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
@@ -268,7 +324,7 @@ export const AddTemplate = ({ projectId }: Props) => {
)}
>
<img
src={`/templates/${template.logo}`}
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template.id}/${template.logo}`}
className={cn(
"object-contain",
viewMode === "detailed" ? "size-24" : "size-16",
@@ -321,7 +377,7 @@ export const AddTemplate = ({ projectId }: Props) => {
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Github className="size-5" />
<GithubIcon className="size-5" />
</Link>
{template.links.website && (
<Link
@@ -383,8 +439,9 @@ export const AddTemplate = ({ projectId }: Props) => {
side="top"
>
<span>
If no server is selected, the application will be
deployed on the server where the user is logged in.
If no server is selected, the application
will be deployed on the server where the
user is logged in.
</span>
</TooltipContent>
</Tooltip>
@@ -430,18 +487,19 @@ export const AddTemplate = ({ projectId }: Props) => {
projectId,
serverId: serverId || undefined,
id: template.id,
baseUrl: customBaseUrl,
});
toast.promise(promise, {
loading: "Setting up...",
success: (_data) => {
success: () => {
utils.project.one.invalidate({
projectId,
});
setOpen(false);
return `${template.name} template created successfully`;
},
error: (_err) => {
return `An error ocurred deploying ${template.name} template`;
error: () => {
return `An error occurred deploying ${template.name} template`;
},
});
}}

View File

@@ -148,7 +148,7 @@ export const HandleProject = ({ projectId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Tesla" {...field} />
<Input placeholder="Vandelay Industries" {...field} />
</FormControl>
<FormMessage />

View File

@@ -115,7 +115,7 @@ export const ShowProjects = () => {
</span>
</div>
)}
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 flex-wrap gap-5">
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
{filteredProjects?.map((project) => {
const emptyServices =
project?.mariadb.length === 0 &&
@@ -186,7 +186,7 @@ export const ShowProjects = () => {
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span>{domain.host}</span>
<span className="truncate">{domain.host}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
@@ -222,7 +222,7 @@ export const ShowProjects = () => {
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span>{domain.host}</span>
<span className="truncate">{domain.host}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>

View File

@@ -1,5 +1,6 @@
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import { AlertBlock } from "@/components/shared/alert-block";
import {
Card,
CardContent,
@@ -23,6 +24,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import Link from "next/link";
const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => {
@@ -100,6 +102,17 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">
{!getIp && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link href="/dashboard/settings" className="text-primary">
{data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to fix the database url connection.
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View File

@@ -2,12 +2,26 @@ import { DialogAction } from "@/components/shared/dialog-action";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
import {
Ban,
CheckCircle2,
HelpCircle,
RefreshCcw,
Terminal,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props {
redisId: string;
}
@@ -63,94 +77,150 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<DialogAction
title="Deploy Redis"
description="Are you sure you want to deploy this redis?"
type="default"
onClick={async () => {
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Redis"
description="Are you sure you want to reload this redis?"
type="default"
onClick={async () => {
await reload({
redisId: redisId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Redis reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Redis");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
{/* <ResetRedis redisId={redisId} appName={data?.appName || ""} /> */}
{data?.applicationStatus === "idle" ? (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Start Redis"
description="Are you sure you want to start this redis?"
title="Deploy Redis"
description="Are you sure you want to deploy this redis?"
type="default"
onClick={async () => {
await start({
redisId: redisId,
})
.then(() => {
toast.success("Redis started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Redis");
});
setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
refetch();
}}
>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5"
>
Deploy
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the Redis database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Redis"
description="Are you sure you want to stop this redis?"
title="Reload Redis"
description="Are you sure you want to reload this redis?"
type="default"
onClick={async () => {
await stop({
await reload({
redisId: redisId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Redis stopped successfully");
toast.success("Redis reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Redis");
toast.error("Error reloading Redis");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5"
>
Reload
<RefreshCcw className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the Redis service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
{data?.applicationStatus === "idle" ? (
<DialogAction
title="Start Redis"
description="Are you sure you want to start this redis?"
type="default"
onClick={async () => {
await start({
redisId: redisId,
})
.then(() => {
toast.success("Redis started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting Redis");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5"
>
Start
<CheckCircle2 className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>
Start the Redis database (requires a previous
successful setup)
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
) : (
<DialogAction
title="Stop Redis"
description="Are you sure you want to stop this redis?"
onClick={async () => {
await stop({
redisId: redisId,
})
.then(() => {
toast.success("Redis stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping Redis");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5"
>
Stop
<Ban className="size-4" />
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running Redis database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}

View File

@@ -119,7 +119,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Tesla" {...field} />
<Input placeholder="Vandelay Industries" {...field} />
</FormControl>
<FormMessage />

View File

@@ -1,10 +1,10 @@
import { api } from "@/utils/api";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { api } from "@/utils/api";
import {
Area,
AreaChart,
@@ -14,6 +14,13 @@ import {
YAxis,
} from "recharts";
export interface RequestDistributionChartProps {
dateRange?: {
from: Date | undefined;
to: Date | undefined;
};
}
const chartConfig = {
views: {
label: "Page Views",
@@ -24,10 +31,22 @@ const chartConfig = {
},
} satisfies ChartConfig;
export const RequestDistributionChart = () => {
const { data: stats } = api.settings.readStats.useQuery(undefined, {
refetchInterval: 1333,
});
export const RequestDistributionChart = ({
dateRange,
}: RequestDistributionChartProps) => {
const { data: stats } = api.settings.readStats.useQuery(
{
dateRange: dateRange
? {
start: dateRange.from?.toISOString(),
end: dateRange.to?.toISOString(),
}
: undefined,
},
{
refetchInterval: 1333,
},
);
return (
<ResponsiveContainer width="100%" height={200}>

View File

@@ -79,7 +79,15 @@ export const priorities = [
icon: Server,
},
];
export const RequestsTable = () => {
export interface RequestsTableProps {
dateRange?: {
from: Date | undefined;
to: Date | undefined;
};
}
export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [search, setSearch] = useState("");
const [selectedRow, setSelectedRow] = useState<LogEntry>();
@@ -98,6 +106,12 @@ export const RequestsTable = () => {
page: pagination,
search,
status: statusFilter,
dateRange: dateRange
? {
start: dateRange.from?.toISOString(),
end: dateRange.to?.toISOString(),
}
: undefined,
},
{
refetchInterval: 1333,

View File

@@ -1,6 +1,7 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Card,
CardContent,
@@ -8,9 +9,29 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { type RouterOutputs, api } from "@/utils/api";
import { ArrowDownUp } from "lucide-react";
import { format } from "date-fns";
import {
ArrowDownUp,
AlertCircle,
InfoIcon,
Calendar as CalendarIcon,
} from "lucide-react";
import Link from "next/link";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { RequestDistributionChart } from "./request-distribution-chart";
import { RequestsTable } from "./requests-table";
@@ -20,17 +41,30 @@ export type LogEntry = NonNullable<
>[0];
export const ShowRequests = () => {
const { data: isLogRotateActive, refetch: refetchLogRotate } =
api.settings.getLogRotateStatus.useQuery();
const { mutateAsync: toggleLogRotate } =
api.settings.toggleLogRotate.useMutation();
const { data: isActive, refetch } =
api.settings.haveActivateRequests.useQuery();
const { mutateAsync: toggleRequests } =
api.settings.toggleRequests.useMutation();
const { data: logCleanupStatus } =
api.settings.getLogCleanupStatus.useQuery();
const { mutateAsync: updateLogCleanup } =
api.settings.updateLogCleanup.useMutation();
const [cronExpression, setCronExpression] = useState<string | null>(null);
const [dateRange, setDateRange] = useState<{
from: Date | undefined;
to: Date | undefined;
}>({
from: undefined,
to: undefined,
});
useEffect(() => {
if (logCleanupStatus) {
setCronExpression(logCleanupStatus.cronExpression || "0 0 * * *");
}
}, [logCleanupStatus]);
return (
<>
<div className="w-full">
@@ -57,7 +91,60 @@ export const ShowRequests = () => {
</AlertBlock>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="flex w-full gap-4 justify-end">
<div className="flex w-full gap-4 justify-end items-center">
<div className="flex-1 flex items-center gap-4">
<div className="flex items-center gap-2">
<Label htmlFor="cron" className="min-w-32">
Log Cleanup Schedule
</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p className="max-w-80">
At the scheduled time, the cleanup job will keep
only the last 1000 entries in the access log file
and signal Traefik to reopen its log files. The
default schedule is daily at midnight (0 0 * * *).
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex-1 flex gap-4">
<Input
id="cron"
placeholder="0 0 * * *"
value={cronExpression || ""}
onChange={(e) => setCronExpression(e.target.value)}
className="max-w-60"
required
/>
<Button
variant="outline"
onClick={async () => {
if (!cronExpression?.trim()) {
toast.error("Please enter a valid cron expression");
return;
}
try {
await updateLogCleanup({
cronExpression: cronExpression,
});
toast.success("Log cleanup schedule updated");
} catch (error) {
toast.error(
`Failed to update log cleanup schedule: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}}
>
Update Schedule
</Button>
</div>
</div>
<DialogAction
title={isActive ? "Deactivate Requests" : "Activate Requests"}
description="You will also need to restart Traefik to apply the changes"
@@ -77,53 +164,81 @@ export const ShowRequests = () => {
>
<Button>{isActive ? "Deactivate" : "Activate"}</Button>
</DialogAction>
<DialogAction
title={
isLogRotateActive
? "Activate Log Rotate"
: "Deactivate Log Rotate"
}
description={
isLogRotateActive
? "This will make the logs rotate on interval 1 day and maximum size of 100 MB and maximum 6 logs"
: "The log rotation will be disabled"
}
onClick={() => {
toggleLogRotate({
enable: !isLogRotateActive,
})
.then(() => {
toast.success(
`Log rotate ${isLogRotateActive ? "activated" : "deactivated"}`,
);
refetchLogRotate();
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Button variant="secondary">
{isLogRotateActive
? "Activate Log Rotate"
: "Deactivate Log Rotate"}
</Button>
</DialogAction>
</div>
<div>
{isActive ? (
<RequestDistributionChart />
) : (
<div className="flex items-center justify-center min-h-[25vh]">
<span className="text-muted-foreground py-6">
You need to activate requests
</span>
{isActive ? (
<>
<div className="flex justify-end mb-4 gap-2">
{(dateRange.from || dateRange.to) && (
<Button
variant="outline"
onClick={() =>
setDateRange({ from: undefined, to: undefined })
}
className="px-3"
>
Clear dates
</Button>
)}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-[300px] justify-start text-left font-normal"
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateRange.from ? (
dateRange.to ? (
<>
{format(dateRange.from, "LLL dd, y")} -{" "}
{format(dateRange.to, "LLL dd, y")}
</>
) : (
format(dateRange.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange.from}
selected={{
from: dateRange.from,
to: dateRange.to,
}}
onSelect={(range) => {
setDateRange({
from: range?.from,
to: range?.to,
});
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
</div>
)}
{isActive && <RequestsTable />}
</div>
<RequestDistributionChart dateRange={dateRange} />
<RequestsTable dateRange={dateRange} />
</>
) : (
<div className="flex flex-col items-center justify-center py-12 gap-4 text-muted-foreground">
<AlertCircle className="size-12 text-muted-foreground/50" />
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">
Requests are not activated
</h3>
<p className="text-sm max-w-md">
Activate requests to see incoming traffic statistics and
monitor your application's usage. After activation, you'll
need to reload Traefik for the changes to take effect.
</p>
</div>
</div>
)}
</CardContent>
</div>
</Card>

View File

@@ -31,6 +31,8 @@ import {
FormDescription,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import copy from "copy-to-clipboard";
import { CodeEditor } from "@/components/shared/code-editor";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
@@ -441,13 +443,16 @@ export const AddApiKey = () => {
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<div className="rounded-md bg-muted p-4 font-mono text-sm break-all">
{newApiKey}
</div>
<CodeEditor
className="font-mono text-sm break-all"
language="properties"
value={newApiKey}
readOnly
/>
<div className="flex justify-end gap-3">
<Button
onClick={() => {
navigator.clipboard.writeText(newApiKey);
copy(newApiKey);
toast.success("API key copied to clipboard");
}}
>

View File

@@ -13,7 +13,11 @@ import Link from "next/link";
import { AddManager } from "./manager/add-manager";
import { AddWorker } from "./workers/add-worker";
export const AddNode = () => {
interface Props {
serverId?: string;
}
export const AddNode = ({ serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
@@ -53,10 +57,10 @@ export const AddNode = () => {
<TabsTrigger value="manager">Manager</TabsTrigger>
</TabsList>
<TabsContent value="worker" className="pt-4">
<AddWorker />
<AddWorker serverId={serverId} />
</TabsContent>
<TabsContent value="manager" className="pt-4">
<AddManager />
<AddManager serverId={serverId} />
</TabsContent>
</Tabs>
</div>

View File

@@ -9,8 +9,12 @@ import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react";
import { toast } from "sonner";
export const AddManager = () => {
const { data } = api.cluster.addManager.useQuery();
interface Props {
serverId?: string;
}
export const AddManager = ({ serverId }: Props) => {
const { data } = api.cluster.addManager.useQuery({ serverId });
return (
<>

View File

@@ -0,0 +1,30 @@
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { useState } from "react";
import { ShowNodes } from "./show-nodes";
interface Props {
serverId: string;
}
export const ShowNodesModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Nodes
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">
<div className="grid w-full gap-1">
<ShowNodes serverId={serverId} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -32,13 +32,25 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { Boxes, HelpCircle, LockIcon, MoreHorizontal } from "lucide-react";
import {
Boxes,
HelpCircle,
LockIcon,
MoreHorizontal,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { AddNode } from "./add-node";
import { ShowNodeData } from "./show-node-data";
export const ShowNodes = () => {
const { data, isLoading, refetch } = api.cluster.getNodes.useQuery();
interface Props {
serverId?: string;
}
export const ShowNodes = ({ serverId }: Props) => {
const { data, isLoading, refetch } = api.cluster.getNodes.useQuery({
serverId,
});
const { data: registry } = api.registry.all.useQuery();
const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation();
@@ -58,14 +70,17 @@ export const ShowNodes = () => {
</div>
{haveAtLeastOneRegistry && (
<div className="flex flex-row gap-2">
<AddNode />
<AddNode serverId={serverId} />
</div>
)}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t min-h-[35vh]">
{haveAtLeastOneRegistry ? (
{isLoading ? (
<div className="flex items-center justify-center w-full h-[40vh]">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
) : haveAtLeastOneRegistry ? (
<div className="grid md:grid-cols-1 gap-4">
{isLoading && <div>Loading...</div>}
<Table>
<TableCaption>
A list of your managers / workers.
@@ -137,6 +152,7 @@ export const ShowNodes = () => {
onClick={async () => {
await deleteNode({
nodeId: node.ID,
serverId,
})
.then(() => {
refetch();

View File

@@ -9,8 +9,12 @@ import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react";
import { toast } from "sonner";
export const AddWorker = () => {
const { data } = api.cluster.addWorker.useQuery();
interface Props {
serverId?: string;
}
export const AddWorker = ({ serverId }: Props) => {
const { data } = api.cluster.addWorker.useQuery({ serverId });
return (
<div>

View File

@@ -207,7 +207,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
<FormControl>
<Input
placeholder="Username"
autoComplete="off"
autoComplete="username"
{...field}
/>
</FormControl>
@@ -227,7 +227,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
<FormControl>
<Input
placeholder="Password"
autoComplete="off"
autoComplete="one-time-code"
{...field}
type="password"
/>

View File

@@ -64,12 +64,12 @@ export const Enable2FA = () => {
const handlePasswordSubmit = async (formData: PasswordForm) => {
setIsPasswordLoading(true);
try {
const { data: enableData } = await authClient.twoFactor.enable({
const { data: enableData, error } = await authClient.twoFactor.enable({
password: formData.password,
});
if (!enableData) {
throw new Error("No data received from server");
throw new Error(error?.message || "Error enabling 2FA");
}
if (enableData.backupCodes) {
@@ -95,7 +95,8 @@ export const Enable2FA = () => {
error instanceof Error ? error.message : "Error setting up 2FA",
);
passwordForm.setError("password", {
message: "Error verifying password",
message:
error instanceof Error ? error.message : "Error setting up 2FA",
});
} finally {
setIsPasswordLoading(false);

View File

@@ -59,15 +59,17 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
.then(async () => {
toast.success("Traefik Reloaded");
})
.catch(() => {
toast.error("Error reloading Traefik");
});
.catch(() => {});
}}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.reload")}</span>
</DropdownMenuItem>
<ShowModalLogs appName="dokploy-traefik" serverId={serverId}>
<ShowModalLogs
appName="dokploy-traefik"
serverId={serverId}
type="standalone"
>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
@@ -108,15 +110,6 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
</span>
</DropdownMenuItem>
{/*
<DockerTerminalModal appName="dokploy-traefik">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<span>Enter the terminal</span>
</DropdownMenuItem>
</DockerTerminalModal> */}
<ManageTraefikPorts serverId={serverId}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}

View File

@@ -42,6 +42,7 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
export const ShowServers = () => {
const { t } = useTranslation("settings");
@@ -328,6 +329,9 @@ export const ShowServers = () => {
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>

View File

@@ -35,7 +35,7 @@ const addServerDomain = z
.object({
domain: z.string().min(1, { message: "URL is required" }),
letsEncryptEmail: z.string(),
certificateType: z.enum(["letsencrypt", "none"]),
certificateType: z.enum(["letsencrypt", "none", "custom"]),
})
.superRefine((data, ctx) => {
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
@@ -193,6 +193,7 @@ export const WebDomain = () => {
);
}}
/>
<div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit">
{t("settings.common.save")}

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -23,6 +24,7 @@ import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import type React from "react";
import { useEffect, useState } from "react";
import { badgeStateColor } from "../../application/logs/show";
const Terminal = dynamic(
() =>
@@ -109,7 +111,10 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}) {container.state}
{container.name} ({container.containerId}){" "}
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>

View File

@@ -19,13 +19,6 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
@@ -44,7 +37,6 @@ interface Props {
const PortSchema = z.object({
targetPort: z.number().min(1, "Target port is required"),
publishedPort: z.number().min(1, "Published port is required"),
publishMode: z.enum(["ingress", "host"]),
});
const TraefikPortsSchema = z.object({
@@ -88,7 +80,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
}, [currentPorts, form]);
const handleAddPort = () => {
append({ targetPort: 0, publishedPort: 0, publishMode: "host" });
append({ targetPort: 0, publishedPort: 0 });
};
const onSubmit = async (data: TraefikPortsForm) => {
@@ -99,9 +91,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
});
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
setOpen(false);
} catch (_error) {
toast.error(t("settings.server.webServer.traefik.portsUpdateError"));
}
} catch (_error) {}
};
return (
@@ -154,7 +144,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<div className="grid gap-4">
{fields.map((field, index) => (
<Card key={field.id}>
<CardContent className="grid grid-cols-[1fr_1fr_1.5fr_auto] gap-4 p-4 transparent">
<CardContent className="grid grid-cols-[1fr_1fr_auto] gap-4 p-4 transparent">
<FormField
control={form.control}
name={`ports.${index}.targetPort`}
@@ -207,39 +197,6 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
)}
/>
<FormField
control={form.control}
name={`ports.${index}.publishMode`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.publishMode",
)}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger className="dark:bg-black">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="host">
Host Mode
</SelectItem>
<SelectItem value="ingress">
Ingress Mode
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-end">
<Button
onClick={() => remove(index)}
@@ -263,30 +220,23 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<span className="text-sm">
<strong>
Each port mapping defines how external traffic reaches
your containers.
your containers through Traefik.
</strong>
<ul className="pt-2">
<li>
<strong>Host Mode:</strong> Directly binds the port
to the host machine.
<ul className="p-2 list-inside list-disc">
<li>
Best for single-node deployments or when you
need guaranteed port availability.
</li>
</ul>
<strong>Target Port:</strong> The port inside your
container that the service is listening on.
</li>
<li>
<strong>Ingress Mode:</strong> Routes through Docker
Swarm's load balancer.
<ul className="p-2 list-inside list-disc">
<li>
Recommended for multi-node deployments and
better scalability.
</li>
</ul>
<strong>Published Port:</strong> The port on your
host machine that will be mapped to the target port.
</li>
</ul>
<p className="mt-2">
All ports are bound directly to the host machine,
allowing Traefik to handle incoming traffic and route
it appropriately to your services.
</p>
</span>
</div>
</AlertBlock>

View File

@@ -21,6 +21,8 @@ import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import type React from "react";
import { useEffect, useState } from "react";
import { badgeStateColor } from "../../application/logs/show";
import { Badge } from "@/components/ui/badge";
export const DockerLogsId = dynamic(
() =>
@@ -36,13 +38,20 @@ interface Props {
appName: string;
children?: React.ReactNode;
serverId?: string;
type?: "standalone" | "swarm";
}
export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
export const ShowModalLogs = ({
appName,
children,
serverId,
type = "swarm",
}: Props) => {
const { data, isLoading } = api.docker.getContainersByAppLabel.useQuery(
{
appName,
serverId,
type,
},
{
enabled: !!appName,
@@ -83,7 +92,10 @@ export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}) {container.state}
{container.name} ({container.containerId}){" "}
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>

View File

@@ -5,6 +5,12 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import type { IUpdateData } from "@dokploy/server/index";
import {
@@ -24,9 +30,17 @@ import { UpdateWebServer } from "./update-webserver";
interface Props {
updateData?: IUpdateData;
children?: React.ReactNode;
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export const UpdateServer = ({ updateData }: Props) => {
export const UpdateServer = ({
updateData,
children,
isOpen: isOpenProp,
onOpenChange: onOpenChangeProp,
}: Props) => {
const [hasCheckedUpdate, setHasCheckedUpdate] = useState(!!updateData);
const [isUpdateAvailable, setIsUpdateAvailable] = useState(
!!updateData?.updateAvailable,
@@ -35,10 +49,10 @@ export const UpdateServer = ({ updateData }: Props) => {
api.settings.getUpdateData.useMutation();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
const [isOpen, setIsOpen] = useState(false);
const [latestVersion, setLatestVersion] = useState(
updateData?.latestVersion ?? "",
);
const [isOpenInternal, setIsOpenInternal] = useState(false);
const handleCheckUpdates = async () => {
try {
@@ -65,28 +79,52 @@ export const UpdateServer = ({ updateData }: Props) => {
}
};
const isOpen = isOpenInternal || isOpenProp;
const onOpenChange = (open: boolean) => {
setIsOpenInternal(open);
onOpenChangeProp?.(open);
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button
variant={updateData ? "outline" : "secondary"}
className="gap-2"
>
{updateData ? (
<>
<span className="flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</span>
Update available
</>
) : (
<>
<Sparkles className="h-4 w-4" />
Updates
</>
)}
</Button>
{children ? (
children
) : (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={updateData ? "outline" : "secondary"}
size="sm"
onClick={() => onOpenChange?.(true)}
>
<Download className="h-4 w-4 flex-shrink-0" />
{updateData ? (
<span className="font-medium truncate group-data-[collapsible=icon]:hidden">
Update Available
</span>
) : (
<span className="font-medium truncate group-data-[collapsible=icon]:hidden">
Check for updates
</span>
)}
{updateData && (
<span className="absolute right-2 flex h-2 w-2 group-data-[collapsible=icon]:hidden">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</span>
)}
</Button>
</TooltipTrigger>
{updateData && (
<TooltipContent side="right" sideOffset={10}>
<p>Update Available</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</DialogTrigger>
<DialogContent className="max-w-lg p-6">
<div className="flex items-center justify-between mb-8">
@@ -217,7 +255,7 @@ export const UpdateServer = ({ updateData }: Props) => {
<div className="space-y-4 flex items-center justify-end">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setIsOpen(false)}>
<Button variant="outline" onClick={() => onOpenChange?.(false)}>
Cancel
</Button>
{isUpdateAvailable ? (

View File

@@ -0,0 +1,119 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/utils/api";
import { DatabaseIcon, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
interface Props {
id: string;
type: "postgres" | "mysql" | "mariadb" | "mongo" | "redis";
}
export const RebuildDatabase = ({ id, type }: Props) => {
const utils = api.useUtils();
const mutationMap = {
postgres: () => api.postgres.rebuild.useMutation(),
mysql: () => api.mysql.rebuild.useMutation(),
mariadb: () => api.mariadb.rebuild.useMutation(),
mongo: () => api.mongo.rebuild.useMutation(),
redis: () => api.redis.rebuild.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]();
const handleRebuild = async () => {
try {
await mutateAsync({
postgresId: type === "postgres" ? id : "",
mysqlId: type === "mysql" ? id : "",
mariadbId: type === "mariadb" ? id : "",
mongoId: type === "mongo" ? id : "",
redisId: type === "redis" ? id : "",
});
toast.success("Database rebuilt successfully");
await utils.invalidate();
} catch (error) {
toast.error("Error rebuilding database", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Card className="bg-background border-destructive/50">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Danger Zone
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h3 className="text-base font-semibold">Rebuild Database</h3>
<p className="text-sm text-muted-foreground">
This action will completely reset your database to its initial
state. All data, tables, and configurations will be removed.
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
isLoading={isLoading}
variant="outline"
className="w-full border-destructive/50 hover:bg-destructive/10 hover:text-destructive text-destructive"
>
<DatabaseIcon className="mr-2 h-4 w-4" />
Rebuild Database
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current database service</li>
<li>Delete all existing data and volumes</li>
<li>Reset to the default configuration</li>
<li>Restart the service with a clean state</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRebuild}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
asChild
>
<Button isLoading={isLoading} type="submit">
Yes, rebuild database
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,20 @@
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { RebuildDatabase } from "./rebuild-database";
interface Props {
id: string;
type: "postgres" | "mysql" | "mariadb" | "mongo" | "redis";
}
export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
return (
<div className="flex w-full flex-col gap-5">
<ShowCustomCommand id={id} type={type} />
<ShowVolumes id={id} type={type} />
<ShowResources id={id} type={type} />
<RebuildDatabase id={id} type={type} />
</div>
);
};

View File

@@ -176,7 +176,7 @@ export default function SwarmMonitorCard({ serverId }: Props) {
</Card>
</div>
<div className="flex flex-row gap-4">
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
{nodes.map((node) => (
<NodeCard key={node.ID} node={node} serverId={serverId} />
))}

View File

@@ -37,8 +37,6 @@ import {
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import {
Collapsible,
@@ -498,7 +496,6 @@ function SidebarLogo() {
const { state } = useSidebar();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery();
// const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: session } = authClient.useSession();
const {
@@ -774,6 +771,7 @@ export default function Page({ children }: Props) {
const pathname = usePathname();
const _currentPath = router.pathname;
const { data: auth } = api.user.get.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const includesProjects = pathname?.includes("/dashboard/project");
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -910,7 +908,7 @@ export default function Page({ children }: Props) {
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Settings</SidebarGroupLabel>
<SidebarMenu className="gap-2">
<SidebarMenu className="gap-1">
{filteredSettings.map((item) => {
const isSingle = item.isSingle !== false;
const isActive = isSingle
@@ -1017,21 +1015,29 @@ export default function Page({ children }: Props) {
</SidebarMenuButton>
</SidebarMenuItem>
))}
{!isCloud && auth?.role === "owner" && (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<UpdateServerButton />
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenu className="flex flex-col gap-2">
{!isCloud && auth?.role === "owner" && (
<SidebarMenuItem>
<UpdateServerButton />
</SidebarMenuItem>
)}
<SidebarMenuItem>
<UserNav />
</SidebarMenuItem>
{dokployVersion && (
<>
<div className="px-3 text-xs text-muted-foreground text-center group-data-[collapsible=icon]:hidden">
Version {dokployVersion}
</div>
<div className="hidden text-xs text-muted-foreground text-center group-data-[collapsible=icon]:block">
{dokployVersion}
</div>
</>
)}
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
@@ -1062,7 +1068,7 @@ export default function Page({ children }: Props) {
</header>
)}
<div className="flex flex-col w-full gap-4 p-4 pt-0">{children}</div>
<div className="flex flex-col w-full p-4 pt-0">{children}</div>
</SidebarInset>
</SidebarProvider>
);

View File

@@ -3,7 +3,14 @@ import type { IUpdateData } from "@dokploy/server/index";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import UpdateServer from "../dashboard/settings/web-server/update-server";
import { Button } from "../ui/button";
import { Download } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
export const UpdateServerButton = () => {
@@ -15,6 +22,7 @@ export const UpdateServerButton = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync: getUpdateData } =
api.settings.getUpdateData.useMutation();
const [isOpen, setIsOpen] = useState(false);
const checkUpdatesIntervalRef = useRef<null | NodeJS.Timeout>(null);
@@ -69,11 +77,47 @@ export const UpdateServerButton = () => {
};
}, []);
return (
updateData.updateAvailable && (
<div>
<UpdateServer updateData={updateData} />
</div>
)
);
return updateData.updateAvailable ? (
<div className="border-t pt-4">
<UpdateServer
updateData={updateData}
isOpen={isOpen}
onOpenChange={setIsOpen}
>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={updateData ? "outline" : "secondary"}
className="w-full"
onClick={() => setIsOpen(true)}
>
<Download className="h-4 w-4 flex-shrink-0" />
{updateData ? (
<span className="font-medium truncate group-data-[collapsible=icon]:hidden">
Update Available
</span>
) : (
<span className="font-medium truncate group-data-[collapsible=icon]:hidden">
Check for updates
</span>
)}
{updateData && (
<span className="absolute right-2 flex h-2 w-2 group-data-[collapsible=icon]:hidden">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</span>
)}
</Button>
</TooltipTrigger>
{updateData && (
<TooltipContent side="right" sideOffset={10}>
<p>Update Available</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</UpdateServer>
</div>
) : null;
};

View File

@@ -9,6 +9,116 @@ import { EditorView } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
import {
autocompletion,
type CompletionContext,
type CompletionResult,
type Completion,
} from "@codemirror/autocomplete";
// Docker Compose completion options
const dockerComposeServices = [
{ label: "services", type: "keyword", info: "Define services" },
{ label: "version", type: "keyword", info: "Specify compose file version" },
{ label: "volumes", type: "keyword", info: "Define volumes" },
{ label: "networks", type: "keyword", info: "Define networks" },
{ label: "configs", type: "keyword", info: "Define configuration files" },
{ label: "secrets", type: "keyword", info: "Define secrets" },
].map((opt) => ({
...opt,
apply: (view: EditorView, completion: Completion) => {
const insert = `${completion.label}:`;
view.dispatch({
changes: {
from: view.state.selection.main.from,
to: view.state.selection.main.to,
insert,
},
selection: { anchor: view.state.selection.main.from + insert.length },
});
},
}));
const dockerComposeServiceOptions = [
{
label: "image",
type: "keyword",
info: "Specify the image to start the container from",
},
{ label: "build", type: "keyword", info: "Build configuration" },
{ label: "command", type: "keyword", info: "Override the default command" },
{ label: "container_name", type: "keyword", info: "Custom container name" },
{
label: "depends_on",
type: "keyword",
info: "Express dependency between services",
},
{ label: "environment", type: "keyword", info: "Add environment variables" },
{
label: "env_file",
type: "keyword",
info: "Add environment variables from a file",
},
{
label: "expose",
type: "keyword",
info: "Expose ports without publishing them",
},
{ label: "ports", type: "keyword", info: "Expose ports" },
{
label: "volumes",
type: "keyword",
info: "Mount host paths or named volumes",
},
{ label: "restart", type: "keyword", info: "Restart policy" },
{ label: "networks", type: "keyword", info: "Networks to join" },
].map((opt) => ({
...opt,
apply: (view: EditorView, completion: Completion) => {
const insert = `${completion.label}: `;
view.dispatch({
changes: {
from: view.state.selection.main.from,
to: view.state.selection.main.to,
insert,
},
selection: { anchor: view.state.selection.main.from + insert.length },
});
},
}));
function dockerComposeComplete(
context: CompletionContext,
): CompletionResult | null {
const word = context.matchBefore(/\w*/);
if (!word) return null;
if (!word.text && !context.explicit) return null;
// Check if we're at the root level
const line = context.state.doc.lineAt(context.pos);
const indentation = /^\s*/.exec(line.text)?.[0].length || 0;
if (indentation === 0) {
return {
from: word.from,
options: dockerComposeServices,
validFor: /^\w*$/,
};
}
// If we're inside a service definition
if (indentation === 4) {
return {
from: word.from,
options: dockerComposeServiceOptions,
validFor: /^\w*$/,
};
}
return null;
}
interface Props extends ReactCodeMirrorProps {
wrapperClassName?: string;
disabled?: boolean;
@@ -45,6 +155,11 @@ export const CodeEditor = ({
? StreamLanguage.define(shell)
: StreamLanguage.define(properties),
props.lineWrapping ? EditorView.lineWrapping : [],
language === "yaml"
? autocompletion({
override: [dockerComposeComplete],
})
: [],
]}
{...props}
editable={!props.disabled}
@@ -55,7 +170,7 @@ export const CodeEditor = ({
)}
/>
{props.disabled && (
<div className="absolute top-0 rounded-md left-0 w-full h-full flex items-center justify-center z-[10] [background:var(--overlay)]" />
<div className="absolute top-0 rounded-md left-0 w-full h-full flex items-center justify-center z-[10] [background:var(--overlay)] h-full" />
)}
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
import type * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100",
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"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",
"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-muted-foreground/80",
className,
)}
{...props}

View File

@@ -0,0 +1 @@
ALTER TABLE "user_temp" ADD COLUMN "logCleanupCron" text;

View File

@@ -0,0 +1,2 @@
ALTER TYPE "public"."certificateType" ADD VALUE 'custom';--> statement-breakpoint
ALTER TABLE "domain" ADD COLUMN "customCertResolver" text;--> statement-breakpoint

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "previewCertificateProvider" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "application" RENAME COLUMN "previewCertificateProvider" TO "previewCustomCertResolver";

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "watchPaths" text[];

View File

@@ -0,0 +1 @@
ALTER TABLE "compose" ADD COLUMN "watchPaths" text[];

View File

@@ -0,0 +1 @@
ALTER TABLE "backup" ADD COLUMN "keepLatestCount" integer;

View File

@@ -924,6 +924,12 @@
"notNull": true,
"default": false
},
"logCleanupCron": {
"name": "logCleanupCron",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enablePaidFeatures": {
"name": "enablePaidFeatures",
"type": "boolean",
@@ -5205,7 +5211,8 @@
"schema": "public",
"values": [
"letsencrypt",
"none"
"none",
"custom"
]
},
"public.composeType": {

View File

@@ -85,6 +85,12 @@
"notNull": true,
"default": "'none'"
},
"previewCertificateProvider": {
"name": "previewCertificateProvider",
"type": "text",
"primaryKey": false,
"notNull": false
},
"previewLimit": {
"name": "previewLimit",
"type": "integer",
@@ -924,6 +930,12 @@
"notNull": true,
"default": false
},
"logCleanupCron": {
"name": "logCleanupCron",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enablePaidFeatures": {
"name": "enablePaidFeatures",
"type": "boolean",
@@ -1128,6 +1140,12 @@
"primaryKey": false,
"notNull": false
},
"customCertResolver": {
"name": "customCertResolver",
"type": "text",
"primaryKey": false,
"notNull": false
},
"applicationId": {
"name": "applicationId",
"type": "text",
@@ -5193,7 +5211,8 @@
"schema": "public",
"values": [
"letsencrypt",
"none"
"none",
"custom"
]
},
"public.composeType": {

View File

@@ -85,6 +85,12 @@
"notNull": true,
"default": "'none'"
},
"previewCustomCertResolver": {
"name": "previewCustomCertResolver",
"type": "text",
"primaryKey": false,
"notNull": false
},
"previewLimit": {
"name": "previewLimit",
"type": "integer",
@@ -924,6 +930,12 @@
"notNull": true,
"default": false
},
"logCleanupCron": {
"name": "logCleanupCron",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enablePaidFeatures": {
"name": "enablePaidFeatures",
"type": "boolean",
@@ -1128,6 +1140,12 @@
"primaryKey": false,
"notNull": false
},
"customCertResolver": {
"name": "customCertResolver",
"type": "text",
"primaryKey": false,
"notNull": false
},
"applicationId": {
"name": "applicationId",
"type": "text",
@@ -5217,7 +5235,8 @@
"schema": "public",
"values": [
"letsencrypt",
"none"
"none",
"custom"
]
},
"public.composeType": {

View File

@@ -44,6 +44,12 @@
"primaryKey": false,
"notNull": false
},
"watchPaths": {
"name": "watchPaths",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"previewBuildArgs": {
"name": "previewBuildArgs",
"type": "text",
@@ -85,6 +91,12 @@
"notNull": true,
"default": "'none'"
},
"previewCustomCertResolver": {
"name": "previewCustomCertResolver",
"type": "text",
"primaryKey": false,
"notNull": false
},
"previewLimit": {
"name": "previewLimit",
"type": "integer",
@@ -924,6 +936,12 @@
"notNull": true,
"default": false
},
"logCleanupCron": {
"name": "logCleanupCron",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enablePaidFeatures": {
"name": "enablePaidFeatures",
"type": "boolean",
@@ -1128,6 +1146,12 @@
"primaryKey": false,
"notNull": false
},
"customCertResolver": {
"name": "customCertResolver",
"type": "text",
"primaryKey": false,
"notNull": false
},
"applicationId": {
"name": "applicationId",
"type": "text",
@@ -5254,7 +5278,8 @@
"schema": "public",
"values": [
"letsencrypt",
"none"
"none",
"custom"
]
},
"public.composeType": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,54 +3,25 @@
* for Docker builds.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
import CopyWebpackPlugin from "copy-webpack-plugin";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/** @type {import("next").NextConfig} */
const nextConfig = {
reactStrictMode: true,
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
transpilePackages: ["@dokploy/server"],
webpack: (config) => {
config.plugins.push(
new CopyWebpackPlugin({
patterns: [
{
from: path.resolve(__dirname, "templates/**/*.yml"),
to: ({ context, absoluteFilename }) => {
const relativePath = path.relative(
path.resolve(__dirname, "templates"),
absoluteFilename || context,
);
return path.join(__dirname, ".next", "templates", relativePath);
},
globOptions: {
ignore: ["**/node_modules/**"],
},
},
],
}),
);
return config;
},
/**
* If you are using `appDir` then you must comment the below `i18n` config out.
*
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ["en"],
defaultLocale: "en",
},
reactStrictMode: true,
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
transpilePackages: ["@dokploy/server"],
/**
* If you are using `appDir` then you must comment the below `i18n` config out.
*
* @see https://github.com/vercel/next.js/issues/41980
*/
i18n: {
locales: ["en"],
defaultLocale: "en",
},
};
export default nextConfig;

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.19.1",
"version": "v0.20.3",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -43,6 +43,7 @@
"@ai-sdk/mistral": "^1.0.6",
"@ai-sdk/openai": "^1.0.12",
"@ai-sdk/openai-compatible": "^0.0.13",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1",
"@codemirror/language": "^6.10.1",
@@ -92,7 +93,7 @@
"adm-zip": "^0.5.14",
"ai": "^4.0.23",
"bcrypt": "5.1.1",
"better-auth": "1.2.0",
"better-auth": "1.2.4",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.4.2",
@@ -100,7 +101,6 @@
"clsx": "^2.1.0",
"cmdk": "^0.2.0",
"copy-to-clipboard": "^3.3.3",
"copy-webpack-plugin": "^12.0.2",
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dotenv": "16.4.5",
@@ -115,6 +115,7 @@
"lodash": "4.17.21",
"lucia": "^3.0.1",
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3",
"next": "^15.0.1",
"next-i18next": "^15.3.1",
@@ -160,6 +161,7 @@
"@types/js-cookie": "^3.0.6",
"@types/js-yaml": "4.0.9",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^18.17.0",
"@types/node-os-utils": "1.3.4",
"@types/node-schedule": "2.1.6",

View File

@@ -3,7 +3,7 @@ import { applications } 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 { IS_CLOUD, shouldDeploy } from "@dokploy/server";
import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
@@ -21,6 +21,7 @@ export default async function handler(
where: eq(applications.refreshToken, refreshToken as string),
with: {
project: true,
bitbucket: true,
},
});
@@ -57,6 +58,20 @@ export default async function handler(
return;
}
} else if (sourceType === "github") {
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
res.status(301).json({ message: "Watch Paths Not Match" });
return;
}
const branchName = extractBranchName(req.headers, req.body);
if (!branchName || branchName !== application.branch) {
res.status(301).json({ message: "Branch Not Match" });
@@ -64,27 +79,62 @@ export default async function handler(
}
} else if (sourceType === "git") {
const branchName = extractBranchName(req.headers, req.body);
if (!branchName || branchName !== application.customGitBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
} else if (sourceType === "gitlab") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
res.status(301).json({ message: "Watch Paths Not Match" });
return;
}
if (!branchName || branchName !== application.gitlabBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
} else if (sourceType === "bitbucket") {
const branchName = extractBranchName(req.headers, req.body);
if (!branchName || branchName !== application.bitbucketBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
<<<<<<< HEAD
} else if (sourceType === "gitea") {
const branchName = extractBranchName(req.headers, req.body);
if (!branchName || branchName !== application.giteaBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
=======
const commitedPaths = await extractCommitedPaths(
req.body,
application.bitbucketOwner,
application.bitbucket?.appPassword || "",
application.bitbucketRepository || "",
);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
commitedPaths,
);
if (!shouldDeployPaths) {
res.status(301).json({ message: "Watch Paths Not Match" });
return;
>>>>>>> fork/canary
}
}
@@ -237,3 +287,42 @@ export const extractBranchName = (headers: any, body: any) => {
return null;
};
export const extractCommitedPaths = async (
body: any,
bitbucketUsername: string | null,
bitbucketAppPassword: string | null,
repository: string | null,
) => {
const changes = body.push?.changes || [];
const commitHashes = changes
.map((change: any) => change.new?.target?.hash)
.filter(Boolean);
const commitedPaths: string[] = [];
for (const commit of commitHashes) {
const url = `https://api.bitbucket.org/2.0/repositories/${bitbucketUsername}/${repository}/diffstat/${commit}`;
try {
const response = await fetch(url, {
headers: {
Authorization: `Basic ${Buffer.from(`${bitbucketUsername}:${bitbucketAppPassword}`).toString("base64")}`,
},
});
const data = await response.json();
for (const value of data.values) {
commitedPaths.push(value.new?.path);
}
} catch (error) {
console.error(
`Error fetching Bitbucket diffstat for commit ${commit}:`,
error instanceof Error ? error.message : "Unknown error",
);
return [];
}
}
return commitedPaths;
};

View File

@@ -3,11 +3,12 @@ import { compose } 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 { IS_CLOUD, shouldDeploy } from "@dokploy/server";
import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import {
extractBranchName,
extractCommitedPaths,
extractCommitMessage,
extractHash,
} from "../[refreshToken]";
@@ -26,6 +27,7 @@ export default async function handler(
where: eq(compose.refreshToken, refreshToken as string),
with: {
project: true,
bitbucket: true,
},
});
@@ -46,16 +48,71 @@ export default async function handler(
if (sourceType === "github") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
res.status(301).json({ message: "Watch Paths Not Match" });
return;
}
if (!branchName || branchName !== composeResult.branch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
} else if (sourceType === "gitlab") {
const branchName = extractBranchName(req.headers, req.body);
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
res.status(301).json({ message: "Watch Paths Not Match" });
return;
}
if (!branchName || branchName !== composeResult.gitlabBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
} else if (sourceType === "bitbucket") {
const branchName = extractBranchName(req.headers, req.body);
if (!branchName || branchName !== composeResult.bitbucketBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
} else if (sourceType === "git") {
const branchName = extractBranchName(req.headers, req.body);
if (!branchName || branchName !== composeResult.customGitBranch) {
res.status(301).json({ message: "Branch Not Match" });
return;
}
const commitedPaths = await extractCommitedPaths(
req.body,
composeResult.bitbucketOwner,
composeResult.bitbucket?.appPassword || "",
composeResult.bitbucketRepository || "",
);
const shouldDeployPaths = shouldDeploy(
composeResult.watchPaths,
commitedPaths,
);
if (!shouldDeployPaths) {
res.status(301).json({ message: "Watch Paths Not Match" });
return;
}
}
try {

View File

@@ -9,6 +9,7 @@ import {
findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId,
removePreviewDeployment,
shouldDeploy,
} from "@dokploy/server";
import { Webhooks } from "@octokit/webhooks";
import { and, eq } from "drizzle-orm";
@@ -95,6 +96,9 @@ export default async function handler(
const deploymentTitle = extractCommitMessage(req.headers, req.body);
const deploymentHash = extractHash(req.headers, req.body);
const owner = githubBody?.repository?.owner?.name;
const normalizedCommits = githubBody?.commits?.flatMap(
(commit: any) => commit.modified,
);
const apps = await db.query.applications.findMany({
where: and(
@@ -116,6 +120,15 @@ export default async function handler(
server: !!app.serverId,
};
const shouldDeployPaths = shouldDeploy(
app.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
continue;
}
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
@@ -151,6 +164,14 @@ export default async function handler(
server: !!composeApp.serverId,
};
const shouldDeployPaths = shouldDeploy(
composeApp.watchPaths,
normalizedCommits,
);
if (!shouldDeployPaths) {
continue;
}
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
await deploy(jobData);

View File

@@ -65,6 +65,7 @@ import {
PlusIcon,
Search,
X,
Trash2,
} from "lucide-react";
import type {
GetServerSidePropsContext,
@@ -72,9 +73,25 @@ import type {
} from "next";
import Head from "next/head";
import { useRouter } from "next/router";
import { type ReactElement, useMemo, useState } from "react";
import { type ReactElement, useMemo, useState, useEffect } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type Services = {
appName: string;
@@ -203,10 +220,47 @@ const Project = (
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
const { projectId } = props;
const { data: auth } = api.user.get.useQuery();
const [sortBy, setSortBy] = useState<string>(() => {
if (typeof window !== "undefined") {
return localStorage.getItem("servicesSort") || "createdAt-desc";
}
return "createdAt-desc";
});
useEffect(() => {
localStorage.setItem("servicesSort", sortBy);
}, [sortBy]);
const sortServices = (services: Services[]) => {
const [field, direction] = sortBy.split("-");
return [...services].sort((a, b) => {
let comparison = 0;
switch (field) {
case "name":
comparison = a.name.localeCompare(b.name);
break;
case "type":
comparison = a.type.localeCompare(b.type);
break;
case "createdAt":
comparison =
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
break;
default:
comparison = 0;
}
return direction === "asc" ? comparison : -comparison;
});
};
const { data, isLoading, refetch } = api.project.one.useQuery({ projectId });
const { data: allProjects } = api.project.all.useQuery();
const router = useRouter();
const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false);
const [selectedTargetProject, setSelectedTargetProject] =
useState<string>("");
const emptyServices =
data?.mariadb?.length === 0 &&
data?.mongo?.length === 0 &&
@@ -254,6 +308,38 @@ const Project = (
const composeActions = {
start: api.compose.start.useMutation(),
stop: api.compose.stop.useMutation(),
move: api.compose.move.useMutation(),
delete: api.compose.delete.useMutation(),
};
const applicationActions = {
move: api.application.move.useMutation(),
delete: api.application.delete.useMutation(),
};
const postgresActions = {
move: api.postgres.move.useMutation(),
delete: api.postgres.remove.useMutation(),
};
const mysqlActions = {
move: api.mysql.move.useMutation(),
delete: api.mysql.remove.useMutation(),
};
const mariadbActions = {
move: api.mariadb.move.useMutation(),
delete: api.mariadb.remove.useMutation(),
};
const redisActions = {
move: api.redis.move.useMutation(),
delete: api.redis.remove.useMutation(),
};
const mongoActions = {
move: api.mongo.move.useMutation(),
delete: api.mongo.remove.useMutation(),
};
const handleBulkStart = async () => {
@@ -296,9 +382,145 @@ const Project = (
setIsBulkActionLoading(false);
};
const handleBulkMove = async () => {
if (!selectedTargetProject) {
toast.error("Please select a target project");
return;
}
let success = 0;
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
try {
const service = filteredServices.find((s) => s.id === serviceId);
if (!service) continue;
switch (service.type) {
case "application":
await applicationActions.move.mutateAsync({
applicationId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "compose":
await composeActions.move.mutateAsync({
composeId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "postgres":
await postgresActions.move.mutateAsync({
postgresId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "mysql":
await mysqlActions.move.mutateAsync({
mysqlId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "mariadb":
await mariadbActions.move.mutateAsync({
mariadbId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "redis":
await redisActions.move.mutateAsync({
redisId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
case "mongo":
await mongoActions.move.mutateAsync({
mongoId: serviceId,
targetProjectId: selectedTargetProject,
});
break;
}
success++;
} catch (error) {
toast.error(
`Error moving service ${serviceId}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
if (success > 0) {
toast.success(`${success} services moved successfully`);
refetch();
}
setSelectedServices([]);
setIsDropdownOpen(false);
setIsMoveDialogOpen(false);
setIsBulkActionLoading(false);
};
const handleBulkDelete = async () => {
let success = 0;
setIsBulkActionLoading(true);
for (const serviceId of selectedServices) {
try {
const service = filteredServices.find((s) => s.id === serviceId);
if (!service) continue;
switch (service.type) {
case "application":
await applicationActions.delete.mutateAsync({
applicationId: serviceId,
});
break;
case "compose":
await composeActions.delete.mutateAsync({
composeId: serviceId,
deleteVolumes: false,
});
break;
case "postgres":
await postgresActions.delete.mutateAsync({
postgresId: serviceId,
});
break;
case "mysql":
await mysqlActions.delete.mutateAsync({
mysqlId: serviceId,
});
break;
case "mariadb":
await mariadbActions.delete.mutateAsync({
mariadbId: serviceId,
});
break;
case "redis":
await redisActions.delete.mutateAsync({
redisId: serviceId,
});
break;
case "mongo":
await mongoActions.delete.mutateAsync({
mongoId: serviceId,
});
break;
}
success++;
} catch (error) {
toast.error(
`Error deleting service ${serviceId}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
if (success > 0) {
toast.success(`${success} services deleted successfully`);
refetch();
}
setSelectedServices([]);
setIsDropdownOpen(false);
setIsBulkActionLoading(false);
};
const filteredServices = useMemo(() => {
if (!applications) return [];
return applications.filter(
const filtered = applications.filter(
(service) =>
(service.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
service.description
@@ -306,7 +528,8 @@ const Project = (
.includes(searchQuery.toLowerCase())) &&
(selectedTypes.length === 0 || selectedTypes.includes(service.type)),
);
}, [applications, searchQuery, selectedTypes]);
return sortServices(filtered);
}, [applications, searchQuery, selectedTypes, sortBy]);
return (
<div>
@@ -380,7 +603,7 @@ const Project = (
</div>
) : (
<>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
@@ -445,11 +668,107 @@ const Project = (
Stop
</Button>
</DialogAction>
{(auth?.role === "owner" ||
auth?.canDeleteServices) && (
<DialogAction
title="Delete Services"
description={`Are you sure you want to delete ${selectedServices.length} services? This action cannot be undone.`}
type="destructive"
onClick={handleBulkDelete}
>
<Button
variant="ghost"
className="w-full justify-start text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</DialogAction>
)}
<Dialog
open={isMoveDialogOpen}
onOpenChange={setIsMoveDialogOpen}
>
<DialogTrigger asChild>
<Button
variant="ghost"
className="w-full justify-start"
>
<FolderInput className="mr-2 h-4 w-4" />
Move
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Services</DialogTitle>
<DialogDescription>
Select the target project to move{" "}
{selectedServices.length} services
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
{allProjects?.filter(
(p) => p.projectId !== projectId,
).length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-4">
<FolderInput className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center">
No other projects available. Create a new
project first to move services.
</p>
</div>
) : (
<Select
value={selectedTargetProject}
onValueChange={setSelectedTargetProject}
>
<SelectTrigger>
<SelectValue placeholder="Select target project" />
</SelectTrigger>
<SelectContent>
{allProjects
?.filter(
(p) => p.projectId !== projectId,
)
.map((project) => (
<SelectItem
key={project.projectId}
value={project.projectId}
>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsMoveDialogOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleBulkMove}
isLoading={isBulkActionLoading}
disabled={
allProjects?.filter(
(p) => p.projectId !== projectId,
).length === 0
}
>
Move Services
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center">
<div className="flex flex-col gap-2 lg:flex-row lg:gap-4 lg:items-center">
<div className="w-full relative">
<Input
placeholder="Filter services..."
@@ -459,6 +778,23 @@ const Project = (
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="lg:w-[280px]">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="createdAt-desc">
Newest first
</SelectItem>
<SelectItem value="createdAt-asc">
Oldest first
</SelectItem>
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
<SelectItem value="type-asc">Type (A-Z)</SelectItem>
<SelectItem value="type-desc">Type (Z-A)</SelectItem>
</SelectContent>
</Select>
<Popover
open={openCombobox}
onOpenChange={setOpenCombobox}

View File

@@ -228,15 +228,15 @@ const Service = (
>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="preview-deployments">
Preview Deployments
</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
</div>

View File

@@ -47,6 +47,7 @@ import { useRouter } from "next/router";
import { type ReactElement, useEffect, useState } from "react";
import { toast } from "sonner";
import superjson from "superjson";
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
type TabState =
| "projects"
@@ -224,12 +225,12 @@ const Service = (
>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="advanced">Advanced</TabsTrigger>
</TabsList>
</div>
@@ -330,6 +331,7 @@ const Service = (
<div className="flex flex-col gap-4 pt-2.5">
<AddCommandCompose composeId={composeId} />
<ShowVolumes id={composeId} type="compose" />
<ShowImport composeId={composeId} />
</div>
</TabsContent>
</Tabs>

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