Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu
da0e726326 feat(preview-deployment): enhance external deployment support
- Add support for external preview deployments with optional GitHub comment handling
- Modify deployment services to conditionally update GitHub issue comments
- Update queue types and deployment worker to handle external deployment flag
- Refactor preview deployment creation to support external deployments
- Improve preview deployment router with more flexible deployment creation logic
2025-03-08 17:07:07 -06:00
604 changed files with 18699 additions and 82994 deletions

View File

@@ -2,7 +2,7 @@ name: Dokploy Docker Build
on:
push:
branches: [main, canary, "1061-custom-docker-service-hostname"]
branches: [main, canary, "feat/better-auth-2"]
env:
IMAGE_NAME: dokploy/dokploy

View File

@@ -1,22 +0,0 @@
name: autofix.ci
on:
push:
branches: [canary]
pull_request:
branches: [canary]
jobs:
format:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup biomeJs
uses: biomejs/setup-biome@v2
- name: Run Biome formatter
run: biome format . --write
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef

View File

@@ -52,7 +52,7 @@ feat: add new feature
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.
We use Node v20.9.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.9.0 && nvm use` in the root directory.
We use Node v20.9.0
```bash
git clone https://github.com/dokploy/dokploy.git
@@ -61,9 +61,9 @@ pnpm install
cp apps/dokploy/.env.example apps/dokploy/.env
```
## Requirements
## Development
- [Docker](/GUIDES.md#docker)
Is required to have **Docker** installed on your machine.
### Setup
@@ -87,8 +87,6 @@ pnpm run dokploy:dev
Go to http://localhost:3000 to see the development server
Note: this project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off.
## Build
```bash
@@ -167,8 +165,86 @@ Thank you for your contribution!
## Templates
To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file.
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 {
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

@@ -29,7 +29,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y curl unzip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next

View File

@@ -1,49 +0,0 @@
# Docker
Here's how to install docker on different operating systems:
## macOS
1. Visit [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop)
2. Download the Docker Desktop installer
3. Double-click the downloaded `.dmg` file
4. Drag Docker to your Applications folder
5. Open Docker Desktop from Applications
6. Follow the onboarding tutorial if desired
## Linux
### Ubuntu
```bash
# Update package index
sudo apt-get update
# Install prerequisites
sudo apt-get install \
apt-transport-https \
ca-certificates \
curl \
gnupg \
lsb-release
# Add Docker's official GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
# Set up stable repository
echo \
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker Engine
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io
```
## Windows
1. Enable WSL2 if not already enabled
2. Visit [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
3. Download the installer
4. Run the installer and follow the prompts
5. Start Docker Desktop from the Start menu

View File

@@ -0,0 +1,242 @@
# 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,7 +9,6 @@ describe("createDomainLabels", () => {
port: 8080,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
certificateType: "none",
applicationId: "",
composeId: "",

View File

@@ -1006,7 +1006,7 @@ services:
volumes:
db-config-testhash:
`);
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (4 Try)", () => {
const composeData = load(composeFileComplex) as ComposeSpecification;
@@ -1115,60 +1115,3 @@ test("Expect to change the suffix in all the possible places (5 Try)", () => {
expect(updatedComposeData).toEqual(expectedDockerComposeExample1);
});
const composeFileBackrest = `
services:
backrest:
image: garethgeorge/backrest:v1.7.3
restart: unless-stopped
ports:
- 9898
environment:
- BACKREST_PORT=9898
- BACKREST_DATA=/data
- BACKREST_CONFIG=/config/config.json
- XDG_CACHE_HOME=/cache
- TZ=\${TZ}
volumes:
- backrest/data:/data
- backrest/config:/config
- backrest/cache:/cache
- /:/userdata:ro
volumes:
backrest:
backrest-cache:
`;
const expectedDockerComposeBackrest = load(`
services:
backrest:
image: garethgeorge/backrest:v1.7.3
restart: unless-stopped
ports:
- 9898
environment:
- BACKREST_PORT=9898
- BACKREST_DATA=/data
- BACKREST_CONFIG=/config/config.json
- XDG_CACHE_HOME=/cache
- TZ=\${TZ}
volumes:
- backrest-testhash/data:/data
- backrest-testhash/config:/config
- backrest-testhash/cache:/cache
- /:/userdata:ro
volumes:
backrest-testhash:
backrest-cache-testhash:
`) as ComposeSpecification;
test("Should handle volume paths with subdirectories correctly", () => {
const composeData = load(composeFileBackrest) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
expect(updatedComposeData).toEqual(expectedDockerComposeBackrest);
});

View File

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

View File

@@ -1,468 +0,0 @@
import type { Schema } from "@dokploy/server/templates";
import type { CompleteTemplate } from "@dokploy/server/templates/processors";
import { processTemplate } from "@dokploy/server/templates/processors";
import { describe, expect, it } from "vitest";
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);
});
it("should handle boolean values in env vars when provided as an array", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: [
"ENABLE_USER_SIGN_UP=false",
"DEBUG_MODE=true",
"SOME_NUMBER=42",
],
mounts: [],
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(3);
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
expect(result.envs).toContain("DEBUG_MODE=true");
expect(result.envs).toContain("SOME_NUMBER=42");
});
it("should handle boolean values in env vars when provided as an object", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {
ENABLE_USER_SIGN_UP: false,
DEBUG_MODE: true,
SOME_NUMBER: 42,
},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(3);
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
expect(result.envs).toContain("DEBUG_MODE=true");
expect(result.envs).toContain("SOME_NUMBER=42");
});
});
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

@@ -14,7 +14,6 @@ import {
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: User = {
https: false,
enablePaidFeatures: false,
metricsConfig: {
containers: {
@@ -74,6 +73,7 @@ beforeEach(() => {
test("Should read the configuration file", () => {
const config: FileConfig = loadOrCreateConfig("dokploy");
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
"dokploy-service-app",
);
@@ -83,7 +83,6 @@ test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseAdmin,
https: true,
certificateType: "letsencrypt",
},
"example.com",

View File

@@ -7,12 +7,6 @@ import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
applicationId: "",
herokuVersion: "",
giteaRepository: "",
giteaOwner: "",
giteaBranch: "",
giteaBuildPath: "",
giteaId: "",
cleanCache: false,
applicationStatus: "done",
appName: "",
autoDeploy: true,
@@ -20,7 +14,6 @@ const baseApp: ApplicationNested = {
branch: null,
dockerBuildStage: "",
registryUrl: "",
watchPaths: [],
buildArgs: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
@@ -30,7 +23,6 @@ const baseApp: ApplicationNested = {
previewPath: "/",
previewPort: 3000,
previewLimit: 0,
previewCustomCertResolver: null,
previewWildcard: "",
project: {
env: "",
@@ -111,7 +103,6 @@ const baseDomain: Domain = {
port: null,
serviceName: "",
composeId: "",
customCertResolver: null,
domainType: "application",
uniqueConfigKey: 1,
previewDeploymentId: "",

View File

@@ -1,61 +0,0 @@
import { describe, expect, test } from "vitest";
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
describe("normalizeS3Path", () => {
test("should handle empty and whitespace-only prefix", () => {
expect(normalizeS3Path("")).toBe("");
expect(normalizeS3Path("/")).toBe("");
expect(normalizeS3Path(" ")).toBe("");
expect(normalizeS3Path("\t")).toBe("");
expect(normalizeS3Path("\n")).toBe("");
expect(normalizeS3Path(" \n \t ")).toBe("");
});
test("should trim whitespace from prefix", () => {
expect(normalizeS3Path(" prefix")).toBe("prefix/");
expect(normalizeS3Path("prefix ")).toBe("prefix/");
expect(normalizeS3Path(" prefix ")).toBe("prefix/");
expect(normalizeS3Path("\tprefix\t")).toBe("prefix/");
expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/");
});
test("should remove leading slashes", () => {
expect(normalizeS3Path("/prefix")).toBe("prefix/");
expect(normalizeS3Path("///prefix")).toBe("prefix/");
});
test("should remove trailing slashes", () => {
expect(normalizeS3Path("prefix/")).toBe("prefix/");
expect(normalizeS3Path("prefix///")).toBe("prefix/");
});
test("should remove both leading and trailing slashes", () => {
expect(normalizeS3Path("/prefix/")).toBe("prefix/");
expect(normalizeS3Path("///prefix///")).toBe("prefix/");
});
test("should handle nested paths", () => {
expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/");
expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/");
expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/");
});
test("should preserve middle slashes", () => {
expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/");
expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/");
});
test("should handle special characters", () => {
expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/");
expect(normalizeS3Path("prefix_with_underscores")).toBe(
"prefix_with_underscores/",
);
expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/");
});
test("should handle the cases from the bug report", () => {
expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/");
expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/");
expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
});
});

View File

@@ -40,7 +40,7 @@ interface Props {
}
const AddRedirectchema = z.object({
replicas: z.number().min(1, "Replicas must be at least 1"),
replicas: z.number(),
registryId: z.string(),
});
@@ -130,11 +130,9 @@ export const ShowClusterSettings = ({ applicationId }: Props) => {
placeholder="1"
{...field}
onChange={(e) => {
const value = e.target.value;
field.onChange(value === "" ? 0 : Number(value));
field.onChange(Number(e.target.value));
}}
type="number"
value={field.value || ""}
/>
</FormControl>

View File

@@ -1,347 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
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";
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

@@ -20,7 +20,7 @@ import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
export enum BuildType {
enum BuildType {
dockerfile = "dockerfile",
heroku_buildpacks = "heroku_buildpacks",
paketo_buildpacks = "paketo_buildpacks",
@@ -29,18 +29,9 @@ export enum BuildType {
railpack = "railpack",
}
const buildTypeDisplayMap: Record<BuildType, string> = {
[BuildType.dockerfile]: "Dockerfile",
[BuildType.railpack]: "Railpack",
[BuildType.nixpacks]: "Nixpacks",
[BuildType.heroku_buildpacks]: "Heroku Buildpacks",
[BuildType.paketo_buildpacks]: "Paketo Buildpacks",
[BuildType.static]: "Static",
};
const mySchema = z.discriminatedUnion("buildType", [
z.object({
buildType: z.literal(BuildType.dockerfile),
buildType: z.literal("dockerfile"),
dockerfile: z
.string({
required_error: "Dockerfile path is required",
@@ -51,88 +42,39 @@ const mySchema = z.discriminatedUnion("buildType", [
dockerBuildStage: z.string().nullable().default(""),
}),
z.object({
buildType: z.literal(BuildType.heroku_buildpacks),
buildType: z.literal("heroku_buildpacks"),
herokuVersion: z.string().nullable().default(""),
}),
z.object({
buildType: z.literal(BuildType.paketo_buildpacks),
buildType: z.literal("paketo_buildpacks"),
}),
z.object({
buildType: z.literal(BuildType.nixpacks),
buildType: z.literal("nixpacks"),
publishDirectory: z.string().optional(),
}),
z.object({
buildType: z.literal(BuildType.static),
buildType: z.literal("static"),
}),
z.object({
buildType: z.literal(BuildType.railpack),
buildType: z.literal("railpack"),
}),
]);
type AddTemplate = z.infer<typeof mySchema>;
interface Props {
applicationId: string;
}
interface ApplicationData {
buildType: BuildType;
dockerfile?: string | null;
dockerContextPath?: string | null;
dockerBuildStage?: string | null;
herokuVersion?: string | null;
publishDirectory?: string | null;
}
function isValidBuildType(value: string): value is BuildType {
return Object.values(BuildType).includes(value as BuildType);
}
const resetData = (data: ApplicationData): AddTemplate => {
switch (data.buildType) {
case BuildType.dockerfile:
return {
buildType: BuildType.dockerfile,
dockerfile: data.dockerfile || "",
dockerContextPath: data.dockerContextPath || "",
dockerBuildStage: data.dockerBuildStage || "",
};
case BuildType.heroku_buildpacks:
return {
buildType: BuildType.heroku_buildpacks,
herokuVersion: data.herokuVersion || "",
};
case BuildType.nixpacks:
return {
buildType: BuildType.nixpacks,
publishDirectory: data.publishDirectory || undefined,
};
case BuildType.paketo_buildpacks:
return {
buildType: BuildType.paketo_buildpacks,
};
case BuildType.static:
return {
buildType: BuildType.static,
};
case BuildType.railpack:
return {
buildType: BuildType.railpack,
};
default:
const buildType = data.buildType as BuildType;
return {
buildType,
} as AddTemplate;
}
};
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } =
api.application.saveBuildType.useMutation();
const { data, refetch } = api.application.one.useQuery(
{ applicationId },
{ enabled: !!applicationId },
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const form = useForm<AddTemplate>({
@@ -143,36 +85,46 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
});
const buildType = form.watch("buildType");
useEffect(() => {
if (data) {
const typedData: ApplicationData = {
...data,
buildType: isValidBuildType(data.buildType)
? (data.buildType as BuildType)
: BuildType.nixpacks, // fallback
};
form.reset(resetData(typedData));
if (data.buildType === "dockerfile") {
form.reset({
buildType: data.buildType,
...(data.buildType && {
dockerfile: data.dockerfile || "",
dockerContextPath: data.dockerContextPath || "",
dockerBuildStage: data.dockerBuildStage || "",
}),
});
} else if (data.buildType === "heroku_buildpacks") {
form.reset({
buildType: data.buildType,
...(data.buildType && {
herokuVersion: data.herokuVersion || "",
}),
});
} else {
form.reset({
buildType: data.buildType,
publishDirectory: data.publishDirectory || undefined,
});
}
}
}, [data, form]);
}, [form.formState.isSubmitSuccessful, form.reset, data, form]);
const onSubmit = async (data: AddTemplate) => {
await mutateAsync({
applicationId,
buildType: data.buildType,
publishDirectory:
data.buildType === BuildType.nixpacks ? data.publishDirectory : null,
dockerfile:
data.buildType === BuildType.dockerfile ? data.dockerfile : null,
data.buildType === "nixpacks" ? data.publishDirectory : null,
dockerfile: data.buildType === "dockerfile" ? data.dockerfile : null,
dockerContextPath:
data.buildType === BuildType.dockerfile ? data.dockerContextPath : null,
data.buildType === "dockerfile" ? data.dockerContextPath : null,
dockerBuildStage:
data.buildType === BuildType.dockerfile ? data.dockerBuildStage : null,
data.buildType === "dockerfile" ? data.dockerBuildStage : null,
herokuVersion:
data.buildType === BuildType.heroku_buildpacks
? data.herokuVersion
: null,
data.buildType === "heroku_buildpacks" ? data.herokuVersion : null,
})
.then(async () => {
toast.success("Build type saved");
@@ -208,143 +160,193 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
control={form.control}
name="buildType"
defaultValue={form.control._defaultValues.buildType}
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Build Type</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="flex flex-col space-y-1"
>
{Object.entries(buildTypeDisplayMap).map(
([value, label]) => (
<FormItem
key={value}
className="flex items-center space-x-3 space-y-0"
>
<FormControl>
<RadioGroupItem value={value} />
</FormControl>
<FormLabel className="font-normal">
{label}
{value === BuildType.railpack && (
<Badge className="ml-2 px-1 text-xs">New</Badge>
)}
</FormLabel>
</FormItem>
),
)}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{buildType === BuildType.heroku_buildpacks && (
<FormField
control={form.control}
name="herokuVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Heroku Version (Optional)</FormLabel>
render={({ field }) => {
return (
<FormItem className="space-y-3">
<FormLabel>Build Type</FormLabel>
<FormControl>
<Input
placeholder="Heroku Version (Default: 24)"
{...field}
value={field.value ?? ""}
/>
<RadioGroup
onValueChange={field.onChange}
value={field.value}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="dockerfile" />
</FormControl>
<FormLabel className="font-normal">
Dockerfile
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="railpack" />
</FormControl>
<FormLabel className="font-normal">
Railpack{" "}
<Badge className="ml-1 text-xs px-1">New</Badge>
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="nixpacks" />
</FormControl>
<FormLabel className="font-normal">
Nixpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="heroku_buildpacks" />
</FormControl>
<FormLabel className="font-normal">
Heroku Buildpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="paketo_buildpacks" />
</FormControl>
<FormLabel className="font-normal">
Paketo Buildpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="static" />
</FormControl>
<FormLabel className="font-normal">Static</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
);
}}
/>
{buildType === "heroku_buildpacks" && (
<FormField
control={form.control}
name="herokuVersion"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Heroku Version (Optional)</FormLabel>
<FormControl>
<Input
placeholder={"Heroku Version (Default: 24)"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
)}
{buildType === BuildType.dockerfile && (
{buildType === "dockerfile" && (
<>
<FormField
control={form.control}
name="dockerfile"
render={({ field }) => (
<FormItem>
<FormLabel>Docker File</FormLabel>
<FormControl>
<Input
placeholder="Path of your docker file"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
return (
<FormItem>
<FormLabel>Docker File</FormLabel>
<FormControl>
<Input
placeholder={"Path of your docker file"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="dockerContextPath"
render={({ field }) => (
<FormItem>
<FormLabel>Docker Context Path</FormLabel>
<FormControl>
<Input
placeholder="Path of your docker context (default: .)"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
return (
<FormItem>
<FormLabel>Docker Context Path</FormLabel>
<FormControl>
<Input
placeholder={
"Path of your docker context default: ."
}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="dockerBuildStage"
render={({ field }) => (
render={({ field }) => {
return (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Docker Build Stage</FormLabel>
<FormDescription>
Allows you to target a specific stage in a
Multi-stage Dockerfile. If empty, Docker defaults to
build the last defined stage.
</FormDescription>
</div>
<FormControl>
<Input
placeholder={"E.g. production"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
</FormItem>
);
}}
/>
</>
)}
{buildType === "nixpacks" && (
<FormField
control={form.control}
name="publishDirectory"
render={({ field }) => {
return (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Docker Build Stage</FormLabel>
<FormLabel>Publish Directory</FormLabel>
<FormDescription>
Allows you to target a specific stage in a Multi-stage
Dockerfile. If empty, Docker defaults to build the
last defined stage.
Allows you to serve a single directory via NGINX after
the build phase. Useful if the final build assets
should be served as a static site.
</FormDescription>
</div>
<FormControl>
<Input
placeholder="E.g. production"
placeholder={"Publish Directory"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{buildType === BuildType.nixpacks && (
<FormField
control={form.control}
name="publishDirectory"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Publish Directory</FormLabel>
<FormDescription>
Allows you to serve a single directory via NGINX after
the build phase. Useful if the final build assets should
be served as a static site.
</FormDescription>
</div>
<FormControl>
<Input
placeholder="Publish Directory"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
);
}}
/>
)}
<div className="flex w-full justify-end">

View File

@@ -41,7 +41,6 @@ import { toast } from "sonner";
import { domain } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { Dices } from "lucide-react";
import Link from "next/link";
import type z from "zod";
type Domain = z.infer<typeof domain>;
@@ -84,29 +83,10 @@ export const AddDomain = ({
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: application?.serverId || "",
});
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
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({
@@ -114,29 +94,13 @@ 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({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
});
form.reset({});
}
}, [form, data, isLoading, domainId]);
// Separate effect for handling custom cert resolver validation
useEffect(() => {
if (certificateType === "custom") {
form.trigger("customCertResolver");
}
}, [certificateType, form]);
}, [form, form.reset, data, isLoading]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
@@ -194,21 +158,6 @@ export const AddDomain = ({
name="host"
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
{application?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
@@ -307,73 +256,34 @@ export const AddDomain = ({
)}
/>
{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>
);
}}
/>
{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>
{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>
);
}}
/>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
</>
/>
)}
</div>
</div>

View File

@@ -71,19 +71,15 @@ 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 || "",
});
}
}, [data, form]);
}, [form.reset, data, form]);
const onSubmit = async (formData: EnvironmentSchema) => {
const onSubmit = async (data: EnvironmentSchema) => {
mutateAsync({
mongoId: id || "",
postgresId: id || "",
@@ -91,7 +87,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
mysqlId: id || "",
mariadbId: id || "",
composeId: id || "",
env: formData.environment,
env: data.environment,
})
.then(async () => {
toast.success("Environments Added");
@@ -102,12 +98,6 @@ 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">
@@ -116,11 +106,6 @@ 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>
@@ -147,8 +132,8 @@ export const ShowEnvironment = ({ id, type }: Props) => {
control={form.control}
name="environment"
render={({ field }) => (
<FormItem>
<FormControl className="">
<FormItem className="w-full">
<FormControl>
<CodeEditor
style={
{
@@ -157,35 +142,21 @@ 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 gap-2">
{hasChanges && (
<Button
type="button"
variant="outline"
onClick={handleCancel}
>
Cancel
</Button>
)}
<Button
isLoading={isLoading}
className="w-fit"
type="submit"
disabled={!hasChanges}
>
<div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit">
Save
</Button>
</div>

View File

@@ -4,7 +4,6 @@ import { Form } from "@/components/ui/form";
import { Secrets } from "@/components/ui/secrets";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -35,32 +34,16 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const form = useForm<EnvironmentSchema>({
defaultValues: {
env: "",
buildArgs: "",
env: data?.env || "",
buildArgs: data?.buildArgs || "",
},
resolver: zodResolver(addEnvironmentSchema),
});
// 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) => {
const onSubmit = async (data: EnvironmentSchema) => {
mutateAsync({
env: formData.env,
buildArgs: formData.buildArgs,
env: data.env,
buildArgs: data.buildArgs,
applicationId,
})
.then(async () => {
@@ -72,13 +55,6 @@ 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}>
@@ -89,16 +65,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
<Secrets
name="env"
title="Environment Settings"
description={
<span>
You can add environment variables to your resource.
{hasChanges && (
<span className="text-yellow-500 ml-2">
(You have unsaved changes)
</span>
)}
</span>
}
description="You can add environment variables to your resource."
placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")}
/>
{data?.buildType === "dockerfile" && (
@@ -122,18 +89,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
placeholder="NPM_TOKEN=xyz"
/>
)}
<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}
>
<div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit">
Save
</Button>
</div>

View File

@@ -1,6 +1,4 @@
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -31,17 +29,10 @@ 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, X } from "lucide-react";
import Link from "next/link";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -57,7 +48,6 @@ 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>;
@@ -83,7 +73,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
},
bitbucketId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(BitbucketProviderSchema),
});
@@ -129,7 +118,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
},
buildPath: data.bitbucketBuildPath || "/",
bitbucketId: data.bitbucketId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -142,7 +130,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
bitbucketBuildPath: data.buildPath,
bitbucketId: data.bitbucketId,
applicationId,
watchPaths: data.watchPaths || [],
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -208,20 +195,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<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>
<FormLabel>Repository</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -389,84 +363,6 @@ 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,11 +115,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input
placeholder="Username"
autoComplete="username"
{...field}
/>
<Input placeholder="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -134,12 +130,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
placeholder="Password"
autoComplete="one-time-code"
{...field}
type="password"
/>
<Input placeholder="Password" {...field} type="password" />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -17,33 +17,23 @@ 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, X } from "lucide-react";
import Link from "next/link";
import { KeyRoundIcon, LockIcon } from "lucide-react";
import { useRouter } from "next/router";
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
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>;
@@ -66,7 +56,6 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
buildPath: "/",
repositoryURL: "",
sshKey: undefined,
watchPaths: [],
},
resolver: zodResolver(GitProviderSchema),
});
@@ -78,7 +67,6 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
branch: data.customGitBranch || "",
buildPath: data.customGitBuildPath || "/",
repositoryURL: data.customGitUrl || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -90,7 +78,6 @@ 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");
@@ -115,22 +102,9 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
name="repositoryURL"
render={({ field }) => (
<FormItem>
<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>
<FormLabel>Repository URL</FormLabel>
<FormControl>
<Input placeholder="Repository URL" {...field} />
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -186,22 +160,19 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</Button>
)}
</div>
<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="branch"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildPath"
@@ -215,85 +186,6 @@ 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

@@ -1,515 +0,0 @@
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
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, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
interface GiteaRepository {
name: string;
url: string;
id: number;
owner: {
username: string;
};
}
interface GiteaBranch {
name: string;
commit: {
id: string;
};
}
const GiteaProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"),
repository: z
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).default([]),
});
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
interface Props {
applicationId: string;
}
export const SaveGiteaProvider = ({ applicationId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGiteaProvider } =
api.application.saveGiteaProvider.useMutation();
const form = useForm<GiteaProvider>({
defaultValues: {
buildPath: "/",
repository: {
owner: "",
repo: "",
},
giteaId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(GiteaProviderSchema),
});
const repository = form.watch("repository");
const giteaId = form.watch("giteaId");
const { data: giteaUrl } = api.gitea.getGiteaUrl.useQuery(
{ giteaId },
{
enabled: !!giteaId,
},
);
const {
data: repositories,
isLoading: isLoadingRepositories,
error,
} = api.gitea.getGiteaRepositories.useQuery(
{
giteaId,
},
{
enabled: !!giteaId,
},
);
const {
data: branches,
fetchStatus,
status,
} = api.gitea.getGiteaBranches.useQuery(
{
owner: repository?.owner,
repositoryName: repository?.repo,
giteaId: giteaId,
},
{
enabled: !!repository?.owner && !!repository?.repo && !!giteaId,
},
);
useEffect(() => {
if (data) {
form.reset({
branch: data.giteaBranch || "",
repository: {
repo: data.giteaRepository || "",
owner: data.giteaOwner || "",
},
buildPath: data.giteaBuildPath || "/",
giteaId: data.giteaId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({
giteaBranch: data.branch,
giteaRepository: data.repository.repo,
giteaOwner: data.repository.owner,
giteaBuildPath: data.buildPath,
giteaId: data.giteaId,
applicationId,
watchPaths: data.watchPaths,
})
.then(async () => {
toast.success("Service Provider Saved");
await refetch();
})
.catch(() => {
toast.error("Error saving the Gitea provider");
});
};
return (
<div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 py-3"
>
{error && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="giteaId"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
form.setValue("repository", {
owner: "",
repo: "",
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Gitea Account" />
</SelectTrigger>
</FormControl>
<SelectContent>
{giteaProviders?.map((giteaProvider) => (
<SelectItem
key={giteaProvider.giteaId}
value={giteaProvider.giteaId}
>
{giteaProvider.gitProvider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`${giteaUrl}/${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"
>
<GiteaIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
(repo: GiteaRepository) =>
repo.name === field.value.repo,
)?.name
: "Select repository"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>
{repositories && repositories.length === 0 && (
<CommandEmpty>
No repositories found.
</CommandEmpty>
)}
{repositories?.map((repo: GiteaRepository) => {
return (
<CommandItem
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
owner: repo.owner.username as string,
repo: repo.name,
});
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
{form.formState.errors.repository && (
<p className={cn("text-sm font-medium text-destructive")}>
Repository is required
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="block w-full">
<FormLabel>Branch</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
" w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
(branch: GiteaBranch) =>
branch.name === field.value,
)?.name
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
className="h-9"
/>
{status === "loading" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches....
</span>
)}
{!repository?.owner && (
<span className="py-6 text-center text-sm text-muted-foreground">
Select a repository
</span>
)}
<ScrollArea className="h-96">
<CommandEmpty>No branch found.</CommandEmpty>
<CommandGroup>
{branches?.map((branch: GiteaBranch) => (
<CommandItem
value={branch.name}
key={branch.commit.id}
onSelect={() => {
form.setValue("branch", branch.name);
}}
>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
branch.name === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
<FormMessage />
</Popover>
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem>
<FormLabel>Build Path</FormLabel>
<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: string, index: number) => (
<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>
)}
/>
</div>
<div className="flex w-full justify-end">
<Button
isLoading={isSavingGiteaProvider}
type="submit"
className="w-fit"
>
Save
</Button>
</div>
</form>
</Form>
</div>
);
};

View File

@@ -1,5 +1,3 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -30,17 +28,10 @@ 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, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -56,7 +47,6 @@ 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>;
@@ -123,7 +113,6 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
},
buildPath: data.buildPath || "/",
githubId: data.githubId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -136,7 +125,6 @@ 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");
@@ -199,20 +187,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<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>
<FormLabel>Repository</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -375,85 +350,7 @@ 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

@@ -1,6 +1,4 @@
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -31,17 +29,10 @@ 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, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -59,7 +50,6 @@ 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>;
@@ -134,7 +124,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
},
buildPath: data.gitlabBuildPath || "/",
gitlabId: data.gitlabId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -149,7 +138,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
applicationId,
gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace,
watchPaths: data.watchPaths || [],
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -215,20 +203,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<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>
<FormLabel>Repository</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -400,85 +375,7 @@ 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

@@ -1,12 +1,10 @@
import { SaveDockerProvider } from "@/components/dashboard/application/general/generic/save-docker-provider";
import { SaveGitProvider } from "@/components/dashboard/application/general/generic/save-git-provider";
import { SaveGiteaProvider } from "@/components/dashboard/application/general/generic/save-gitea-provider";
import { SaveGithubProvider } from "@/components/dashboard/application/general/generic/save-github-provider";
import {
BitbucketIcon,
DockerIcon,
GitIcon,
GiteaIcon,
GithubIcon,
GitlabIcon,
} from "@/components/icons/data-tools-icons";
@@ -20,14 +18,7 @@ import { SaveBitbucketProvider } from "./save-bitbucket-provider";
import { SaveDragNDrop } from "./save-drag-n-drop";
import { SaveGitlabProvider } from "./save-gitlab-provider";
type TabState =
| "github"
| "docker"
| "git"
| "drop"
| "gitlab"
| "bitbucket"
| "gitea";
type TabState = "github" | "docker" | "git" | "drop" | "gitlab" | "bitbucket";
interface Props {
applicationId: string;
@@ -38,7 +29,6 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } =
api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: application } = api.application.one.useQuery({ applicationId });
const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
@@ -88,13 +78,6 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
<BitbucketIcon className="size-4 text-current fill-current" />
Bitbucket
</TabsTrigger>
<TabsTrigger
value="gitea"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
<GiteaIcon className="size-4 text-current fill-current" />
Gitea
</TabsTrigger>
<TabsTrigger
value="docker"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
@@ -179,26 +162,6 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
</div>
)}
</TabsContent>
<TabsContent value="gitea" className="w-full p-2">
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GiteaIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Gitea, you need to configure your account
first. Please, go to{" "}
<Link
href="/dashboard/settings/git-providers"
className="text-foreground"
>
Settings
</Link>{" "}
to do so.
</span>
</div>
)}
</TabsContent>
<TabsContent value="docker" className="w-full p-2">
<SaveDockerProvider applicationId={applicationId} />
</TabsContent>

View File

@@ -4,22 +4,8 @@ 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 { api } from "@/utils/api";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import {
Ban,
CheckCircle2,
Hammer,
RefreshCcw,
Rocket,
Terminal,
} from "lucide-react";
import { Ban, CheckCircle2, Hammer, RefreshCcw, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -55,224 +41,141 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
<DialogAction
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await deploy({
applicationId: applicationId,
<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`,
);
})
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying application");
});
}}
.catch(() => {
toast.error("Error deploying application");
});
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</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="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
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();
})
.then(() => {
toast.success("Application reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading application");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the application without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
.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();
})
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Hammer className="size-4 mr-1" />
Rebuild
</div>
</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>
Rebuild
<Hammer className="size-4" />
</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,
{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();
})
.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 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</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,
.catch(() => {
toast.error("Error starting application");
});
}}
>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
</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();
})
.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 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running application</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
.catch(() => {
toast.error("Error stopping application");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
</Button>
</DialogAction>
)}
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Terminal className="size-4 mr-1" />
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
aria-label="Toggle italic"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
@@ -287,29 +190,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
/>
</div>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Clean Cache</span>
<Switch
aria-label="Toggle clean cache"
checked={data?.cleanCache || false}
onCheckedChange={async (enabled) => {
await update({
applicationId,
cleanCache: enabled,
})
.then(async () => {
toast.success("Clean Cache Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating Clean Cache");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
className="flex flex-row gap-2 items-center"
/>
</div>
</CardContent>

View File

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

View File

@@ -35,30 +35,16 @@ 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", "custom"]),
previewCustomCertResolver: z.string().optional(),
})
.superRefine((input, ctx) => {
if (
input.previewCertificateType === "custom" &&
!input.previewCustomCertResolver
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["previewCustomCertResolver"],
message: "Required",
});
}
});
const schema = z.object({
env: z.string(),
buildArgs: z.string(),
wildcardDomain: z.string(),
port: z.number(),
previewLimit: z.number(),
previewHttps: z.boolean(),
previewPath: z.string(),
previewCertificateType: z.enum(["letsencrypt", "none"]),
});
type Schema = z.infer<typeof schema>;
@@ -104,7 +90,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: data.previewHttps || false,
previewPath: data.previewPath || "/",
previewCertificateType: data.previewCertificateType || "none",
previewCustomCertResolver: data.previewCustomCertResolver || "",
});
}
}, [data]);
@@ -120,7 +105,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
previewHttps: formData.previewHttps,
previewPath: formData.previewPath,
previewCertificateType: formData.previewCertificateType,
previewCustomCertResolver: formData.previewCustomCertResolver,
})
.then(() => {
toast.success("Preview Deployments settings updated");
@@ -200,6 +184,10 @@ 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>
@@ -250,7 +238,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -258,25 +245,6 @@ 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">
@@ -298,11 +266,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
})
.then(() => {
refetch();
toast.success(
checked
? "Preview deployments enabled"
: "Preview deployments disabled",
);
toast.success("Preview deployments enabled");
})
.catch((error) => {
toast.error(error.message);

View File

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

View File

@@ -41,7 +41,6 @@ import {
import { domainCompose } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import type z from "zod";
type Domain = z.infer<typeof domainCompose>;
@@ -103,22 +102,8 @@ export const AddDomainCompose = ({
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: compose?.serverId || "",
});
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");
@@ -131,21 +116,11 @@ 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({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
});
form.reset({});
}
}, [form, form.reset, data, isLoading]);
@@ -319,21 +294,6 @@ export const AddDomainCompose = ({
name="host"
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
{compose?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
@@ -433,55 +393,33 @@ 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>
<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>
)}
/>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
</>
/>
)}
</div>
</div>

View File

@@ -1,15 +1,8 @@
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 { api } from "@/utils/api";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Ban, CheckCircle2, Hammer, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
@@ -34,180 +27,116 @@ export const ComposeActions = ({ composeId }: Props) => {
api.compose.stop.useMutation();
return (
<div className="flex flex-row gap-4 w-full flex-wrap ">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
<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" ? (
<DialogAction
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
title="Start Compose"
description="Are you sure you want to start this compose?"
type="default"
onClick={async () => {
await deploy({
await start({
composeId: composeId,
})
.then(() => {
toast.success("Compose deployed successfully");
toast.success("Compose started successfully");
refetch();
router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
toast.error("Error starting compose");
});
}}
>
<Button
variant="default"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</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 variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
</Button>
</DialogAction>
) : (
<DialogAction
title="Reload Compose"
description="Are you sure you want to reload this compose?"
type="default"
title="Stop Compose"
description="Are you sure you want to stop this compose?"
onClick={async () => {
await redeploy({
await stop({
composeId: composeId,
})
.then(() => {
toast.success("Compose reloaded successfully");
toast.success("Compose stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading compose");
toast.error("Error stopping compose");
});
}}
>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Reload the compose without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
</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 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</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 group focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</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 || ""}
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Terminal className="size-4 mr-1" />
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
aria-label="Toggle italic"
checked={data?.autoDeploy || false}
onCheckedChange={async (enabled) => {
await update({
@@ -222,7 +151,7 @@ export const ComposeActions = ({ composeId }: Props) => {
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
className="flex flex-row gap-2 items-center"
/>
</div>
</div>

View File

@@ -97,7 +97,6 @@ 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

@@ -1,6 +1,4 @@
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -31,17 +29,10 @@ 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, X } from "lucide-react";
import Link from "next/link";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -57,7 +48,6 @@ 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>;
@@ -83,7 +73,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
},
bitbucketId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(BitbucketProviderSchema),
});
@@ -129,7 +118,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
},
composePath: data.composePath,
bitbucketId: data.bitbucketId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -144,7 +132,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "bitbucket",
composeStatus: "idle",
watchPaths: data.watchPaths,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -210,20 +197,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<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>
<FormLabel>Repository</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -391,84 +365,6 @@ 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,5 +1,3 @@
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Form,
@@ -19,16 +17,9 @@ 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, X } from "lucide-react";
import Link from "next/link";
import { KeyRoundIcon, LockIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -42,7 +33,6 @@ 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>;
@@ -64,7 +54,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
repositoryURL: "",
composePath: "./docker-compose.yml",
sshKey: undefined,
watchPaths: [],
},
resolver: zodResolver(GitProviderSchema),
});
@@ -76,7 +65,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
branch: data.customGitBranch || "",
repositoryURL: data.customGitUrl || "",
composePath: data.composePath,
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -89,8 +77,6 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composeId,
sourceType: "git",
composePath: values.composePath,
composeStatus: "idle",
watchPaths: values.watchPaths || [],
})
.then(async () => {
toast.success("Git Provider Saved");
@@ -115,22 +101,11 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
name="repositoryURL"
render={({ field }) => (
<FormItem>
<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>
<FormLabel className="flex flex-row justify-between">
Repository URL
</FormLabel>
<FormControl>
<Input placeholder="Repository URL" {...field} />
<Input placeholder="git@bitbucket.org" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -216,85 +191,6 @@ 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,483 +0,0 @@
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
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 type { Repository } from "@/utils/gitea-utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const GiteaProviderSchema = z.object({
composePath: z.string().min(1),
repository: z
.object({
repo: z.string().min(1, "Repo is required"),
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).optional(),
});
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
interface Props {
composeId: string;
}
export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGiteaProvider } =
api.compose.update.useMutation();
const form = useForm<GiteaProvider>({
defaultValues: {
composePath: "./docker-compose.yml",
repository: {
owner: "",
repo: "",
},
giteaId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(GiteaProviderSchema),
});
const repository = form.watch("repository");
const giteaId = form.watch("giteaId");
const { data: giteaUrl } = api.gitea.getGiteaUrl.useQuery(
{ giteaId },
{
enabled: !!giteaId,
},
);
const {
data: repositories,
isLoading: isLoadingRepositories,
error,
} = api.gitea.getGiteaRepositories.useQuery<Repository[]>(
{
giteaId,
},
{
enabled: !!giteaId,
},
);
const {
data: branches,
fetchStatus,
status,
} = api.gitea.getGiteaBranches.useQuery(
{
owner: repository?.owner,
repositoryName: repository?.repo,
giteaId: giteaId,
},
{
enabled: !!repository?.owner && !!repository?.repo && !!giteaId,
},
);
useEffect(() => {
if (data) {
form.reset({
branch: data.giteaBranch || "",
repository: {
repo: data.giteaRepository || "",
owner: data.giteaOwner || "",
},
composePath: data.composePath || "./docker-compose.yml",
giteaId: data.giteaId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({
giteaBranch: data.branch,
giteaRepository: data.repository.repo,
giteaOwner: data.repository.owner,
composePath: data.composePath,
giteaId: data.giteaId,
composeId,
sourceType: "gitea",
composeStatus: "idle",
watchPaths: data.watchPaths,
} as any)
.then(async () => {
toast.success("Service Provider Saved");
await refetch();
})
.catch(() => {
toast.error("Error saving the Gitea provider");
});
};
return (
<div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 py-3"
>
{error && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="giteaId"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
field.onChange(value);
form.setValue("repository", {
owner: "",
repo: "",
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Gitea Account" />
</SelectTrigger>
</FormControl>
<SelectContent>
{giteaProviders?.map((giteaProvider) => (
<SelectItem
key={giteaProvider.giteaId}
value={giteaProvider.giteaId}
>
{giteaProvider.gitProvider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
<Link
href={`${giteaUrl}/${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"
>
<GiteaIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoadingRepositories
? "Loading...."
: field.value.owner
? repositories?.find(
(repo) => repo.name === field.value.repo,
)?.name
: "Select repository"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
className="h-9"
/>
{isLoadingRepositories && (
<span className="py-6 text-center text-sm">
Loading Repositories....
</span>
)}
<CommandEmpty>No repositories found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
key={repo.url}
value={repo.name}
onSelect={() => {
form.setValue("repository", {
owner: repo.owner.username,
repo: repo.name,
});
form.setValue("branch", "");
}}
>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
{form.formState.errors.repository && (
<p className={cn("text-sm font-medium text-destructive")}>
Repository is required
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="block w-full">
<FormLabel>Branch</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{status === "loading" && fetchStatus === "fetching"
? "Loading...."
: field.value
? branches?.find(
(branch) => branch.name === field.value,
)?.name
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branches..."
className="h-9"
/>
<CommandEmpty>No branches found.</CommandEmpty>
<ScrollArea className="h-96">
<CommandGroup>
{branches?.map((branch) => (
<CommandItem
key={branch.name}
value={branch.name}
onSelect={() =>
form.setValue("branch", branch.name)
}
>
<span className="flex items-center gap-2">
{branch.name}
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
branch.name === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
{form.formState.errors.branch && (
<p className={cn("text-sm font-medium text-destructive")}>
Branch is required
</p>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="composePath"
render={({ field }) => (
<FormItem>
<FormLabel>Compose Path</FormLabel>
<FormControl>
<Input placeholder="docker-compose.yml" {...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>
<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="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>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" isLoading={isSavingGiteaProvider}>
Save
</Button>
</div>
</form>
</Form>
</div>
);
};

View File

@@ -1,5 +1,3 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -30,17 +28,10 @@ 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, X } from "lucide-react";
import Link from "next/link";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -56,7 +47,6 @@ 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>;
@@ -81,7 +71,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
},
githubId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(GithubProviderSchema),
});
@@ -124,7 +113,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
},
composePath: data.composePath,
githubId: data.githubId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -139,7 +127,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
githubId: data.githubId,
sourceType: "github",
composeStatus: "idle",
watchPaths: data.watchPaths,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -196,25 +183,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<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>
<FormLabel>Repository</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -382,84 +357,6 @@ 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

@@ -1,6 +1,4 @@
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -31,17 +29,10 @@ 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, X } from "lucide-react";
import Link from "next/link";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -59,7 +50,6 @@ 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>;
@@ -86,7 +76,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
},
gitlabId: "",
branch: "",
watchPaths: [],
},
resolver: zodResolver(GitlabProviderSchema),
});
@@ -135,7 +124,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
},
composePath: data.composePath,
gitlabId: data.gitlabId || "",
watchPaths: data.watchPaths || [],
});
}
}, [form.reset, data, form]);
@@ -152,7 +140,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabPathNamespace: data.repository.gitlabPathNamespace,
sourceType: "gitlab",
composeStatus: "idle",
watchPaths: data.watchPaths,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -212,25 +199,13 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="repository"
render={({ field }) => (
<FormItem className="md:col-span-2 flex flex-col">
<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>
<FormLabel>Repository</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
@@ -407,84 +382,6 @@ 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

@@ -1,7 +1,6 @@
import {
BitbucketIcon,
GitIcon,
GiteaIcon,
GithubIcon,
GitlabIcon,
} from "@/components/icons/data-tools-icons";
@@ -15,11 +14,10 @@ import { ComposeFileEditor } from "../compose-file-editor";
import { ShowConvertedCompose } from "../show-converted-compose";
import { SaveBitbucketProviderCompose } from "./save-bitbucket-provider-compose";
import { SaveGitProviderCompose } from "./save-git-provider-compose";
import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose";
import { SaveGithubProviderCompose } from "./save-github-provider-compose";
import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose";
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea";
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket";
interface Props {
composeId: string;
}
@@ -29,11 +27,9 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } =
api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: compose } = api.compose.one.useQuery({ composeId });
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
@@ -58,21 +54,21 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
setSab(e as TabState);
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4">
<TabsList className="md:grid md:w-fit md:grid-cols-6 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
<div className="flex flex-row items-center justify-between w-full gap-4">
<TabsList className="md:grid md:w-fit md:grid-cols-5 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
<TabsTrigger
value="github"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
<GithubIcon className="size-4 text-current fill-current" />
GitHub
Github
</TabsTrigger>
<TabsTrigger
value="gitlab"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
<GitlabIcon className="size-4 text-current fill-current" />
GitLab
Gitlab
</TabsTrigger>
<TabsTrigger
value="bitbucket"
@@ -81,12 +77,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
<BitbucketIcon className="size-4 text-current fill-current" />
Bitbucket
</TabsTrigger>
<TabsTrigger
value="gitea"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
<GiteaIcon className="size-4 text-current fill-current" /> Gitea
</TabsTrigger>
<TabsTrigger
value="git"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
@@ -98,12 +89,11 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
value="raw"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
>
<CodeIcon className="size-4" />
<CodeIcon className="size-4 " />
Raw
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="github" className="w-full p-2">
{githubProviders && githubProviders?.length > 0 ? (
<SaveGithubProviderCompose composeId={composeId} />
@@ -164,26 +154,6 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
</div>
)}
</TabsContent>
<TabsContent value="gitea" className="w-full p-2">
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GiteaIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Gitea, you need to configure your account
first. Please, go to{" "}
<Link
href="/dashboard/settings/git-providers"
className="text-foreground"
>
Settings
</Link>{" "}
to do so.
</span>
</div>
)}
</TabsContent>
<TabsContent value="git" className="w-full p-2">
<SaveGitProviderCompose composeId={composeId} />
</TabsContent>

View File

@@ -147,11 +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 Isolated Deployment ({data?.appName})
</FormLabel>
<FormLabel>Enable Randomize ({data?.appName})</FormLabel>
<FormDescription>
Enable isolated deployment to the compose file.
Enable randomize to the compose file.
</FormDescription>
</div>
<FormControl>

View File

@@ -62,11 +62,6 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<AlertBlock type="info">
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</AlertBlock>
<div className="flex flex-row gap-2 justify-end">
<Button
variant="secondary"

View File

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

View File

@@ -54,14 +54,13 @@ 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>;
interface Props {
databaseId: string;
databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
databaseType: "postgres" | "mariadb" | "mysql" | "mongo";
refetch: () => void;
}
@@ -78,19 +77,17 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
},
resolver: zodResolver(AddPostgresBackup1Schema),
});
useEffect(() => {
form.reset({
database: databaseType === "web-server" ? "dokploy" : "",
database: "",
destinationId: "",
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
@@ -112,11 +109,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
? {
mongoId: databaseId,
}
: databaseType === "web-server"
? {
userId: databaseId,
}
: undefined;
: undefined;
await createBackup({
destinationId: data.destinationId,
@@ -124,7 +117,6 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
keepLatestCount: data.keepLatestCount,
databaseType,
...getDatabaseId,
})
@@ -240,11 +232,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
<FormItem>
<FormLabel>Database</FormLabel>
<FormControl>
<Input
disabled={databaseType === "web-server"}
placeholder={"dokploy"}
{...field}
/>
<Input placeholder={"dokploy"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -277,7 +265,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
<Input placeholder={"dokploy/"} {...field} />
</FormControl>
<FormDescription>
Use if you want to back up in a specific path of your
Use if you want to storage in a specific path of your
destination/bucket
</FormDescription>
@@ -286,29 +274,6 @@ 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

@@ -1,388 +0,0 @@
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { debounce } from "lodash";
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { ServiceType } from "../../application/advanced/show-resources";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
interface Props {
databaseId: string;
databaseType: Exclude<ServiceType, "application" | "redis"> | "web-server";
serverId?: string | null;
}
const RestoreBackupSchema = z.object({
destinationId: z
.string({
required_error: "Please select a destination",
})
.min(1, {
message: "Destination is required",
}),
backupFile: z
.string({
required_error: "Please select a backup file",
})
.min(1, {
message: "Backup file is required",
}),
databaseName: z
.string({
required_error: "Please enter a database name",
})
.min(1, {
message: "Database name is required",
}),
});
type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
export const RestoreBackup = ({
databaseId,
databaseType,
serverId,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const { data: destinations = [] } = api.destination.all.useQuery();
const form = useForm<RestoreBackup>({
defaultValues: {
destinationId: "",
backupFile: "",
databaseName: databaseType === "web-server" ? "dokploy" : "",
},
resolver: zodResolver(RestoreBackupSchema),
});
const destionationId = form.watch("destinationId");
const debouncedSetSearch = debounce((value: string) => {
setDebouncedSearchTerm(value);
}, 150);
const handleSearchChange = (value: string) => {
setSearch(value);
debouncedSetSearch(value);
};
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
{
destinationId: destionationId,
search: debouncedSearchTerm,
serverId: serverId ?? "",
},
{
enabled: isOpen && !!destionationId,
},
);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
// const { mutateAsync: restore, isLoading: isRestoring } =
// api.backup.restoreBackup.useMutation();
api.backup.restoreBackupWithLogs.useSubscription(
{
databaseId,
databaseType,
databaseName: form.watch("databaseName"),
backupFile: form.watch("backupFile"),
destinationId: form.watch("destinationId"),
},
{
enabled: isDeploying,
onData(log) {
if (!isDrawerOpen) {
setIsDrawerOpen(true);
}
if (log === "Restore completed successfully!") {
setIsDeploying(false);
}
const parsedLogs = parseLogs(log);
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
},
onError(error) {
console.error("Restore logs error:", error);
setIsDeploying(false);
},
},
);
const onSubmit = async (_data: RestoreBackup) => {
setIsDeploying(true);
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center">
<RotateCcw className="mr-2 size-4" />
Restore Backup
</DialogTitle>
<DialogDescription>
Select a destination and search for backup files
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-restore-backup"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="destinationId"
render={({ field }) => (
<FormItem className="">
<FormLabel>Destination</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{field.value
? destinations.find(
(d) => d.destinationId === field.value,
)?.name
: "Select Destination"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search destinations..."
className="h-9"
/>
<CommandEmpty>No destinations found.</CommandEmpty>
<ScrollArea className="h-64">
<CommandGroup>
{destinations.map((destination) => (
<CommandItem
value={destination.destinationId}
key={destination.destinationId}
onSelect={() => {
form.setValue(
"destinationId",
destination.destinationId,
);
}}
>
{destination.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
destination.destinationId === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="backupFile"
render={({ field }) => (
<FormItem className="">
<FormLabel className="flex items-center justify-between">
Search Backup Files
{field.value && (
<Badge variant="outline">
{field.value}
<Copy
className="ml-2 size-4 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
copy(field.value);
toast.success("Backup file copied to clipboard");
}}
/>
</Badge>
)}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{field.value || "Search and select a backup file"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search backup files..."
value={search}
onValueChange={handleSearchChange}
className="h-9"
/>
{isLoading ? (
<div className="py-6 text-center text-sm">
Loading backup files...
</div>
) : files.length === 0 && search ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No backup files found for "{search}"
</div>
) : files.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No backup files available
</div>
) : (
<ScrollArea className="h-64">
<CommandGroup>
{files.map((file) => (
<CommandItem
value={file}
key={file}
onSelect={() => {
form.setValue("backupFile", file);
setSearch(file);
setDebouncedSearchTerm(file);
}}
>
<div className="flex w-full justify-between">
<span>{file}</span>
</div>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
file === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
)}
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="databaseName"
render={({ field }) => (
<FormItem className="">
<FormLabel>Database Name</FormLabel>
<FormControl>
<Input
disabled={databaseType === "web-server"}
{...field}
placeholder="Enter database name"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
isLoading={isDeploying}
form="hook-form-restore-backup"
type="submit"
disabled={!form.watch("backupFile")}
>
Restore
</Button>
</DialogFooter>
</form>
</Form>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
// refetch();
}}
filteredLogs={filteredLogs}
/>
</DialogContent>
</Dialog>
);
};

View File

@@ -14,23 +14,18 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { Database, DatabaseBackup, Play, Trash2 } from "lucide-react";
import { DatabaseBackup, Play, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import type { ServiceType } from "../../application/advanced/show-resources";
import { AddBackup } from "./add-backup";
import { RestoreBackup } from "./restore-backup";
import { UpdateBackup } from "./update-backup";
interface Props {
id: string;
type: Exclude<ServiceType, "application" | "redis"> | "web-server";
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 }),
@@ -38,7 +33,6 @@ export const ShowBackups = ({ id, type }: Props) => {
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
};
const { data } = api.destination.all.useQuery();
const { data: postgres, refetch } = queryMap[type]
@@ -50,7 +44,6 @@ export const ShowBackups = ({ id, type }: Props) => {
mysql: () => api.backup.manualBackupMySql.useMutation(),
mariadb: () => api.backup.manualBackupMariadb.useMutation(),
mongo: () => api.backup.manualBackupMongo.useMutation(),
"web-server": () => api.backup.manualBackupWebServer.useMutation(),
};
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
@@ -66,10 +59,7 @@ export const ShowBackups = ({ id, type }: Props) => {
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-xl flex flex-row gap-2">
<Database className="size-6 text-muted-foreground" />
Backups
</CardTitle>
<CardTitle className="text-xl">Backups</CardTitle>
<CardDescription>
Add backups to your database to save the data to a different
provider.
@@ -77,20 +67,7 @@ export const ShowBackups = ({ id, type }: Props) => {
</div>
{postgres && postgres?.backups?.length > 0 && (
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
{type !== "web-server" && (
<AddBackup
databaseId={id}
databaseType={type}
refetch={refetch}
/>
)}
<RestoreBackup
databaseId={id}
databaseType={type}
serverId={"serverId" in postgres ? postgres.serverId : undefined}
/>
</div>
<AddBackup databaseId={id} databaseType={type} refetch={refetch} />
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
@@ -117,20 +94,11 @@ export const ShowBackups = ({ id, type }: Props) => {
<span className="text-base text-muted-foreground">
No backups configured
</span>
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<AddBackup
databaseId={id}
databaseType={type}
refetch={refetch}
/>
<RestoreBackup
databaseId={id}
databaseType={type}
serverId={
"serverId" in postgres ? postgres.serverId : undefined
}
/>
</div>
<AddBackup
databaseId={id}
databaseType={type}
refetch={refetch}
/>
</div>
) : (
<div className="flex flex-col pt-2">
@@ -138,7 +106,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-6 flex-col gap-8">
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-5 flex-col gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Destination</span>
<span className="text-sm text-muted-foreground">
@@ -169,12 +137,6 @@ 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}>
@@ -183,12 +145,8 @@ export const ShowBackups = ({ id, type }: Props) => {
<Button
type="button"
variant="ghost"
isLoading={
isManualBackup &&
activeManualBackup === backup.backupId
}
isLoading={isManualBackup}
onClick={async () => {
setActiveManualBackup(backup.backupId);
await manualBackup({
backupId: backup.backupId as string,
})
@@ -202,7 +160,6 @@ export const ShowBackups = ({ id, type }: Props) => {
"Error creating the manual backup",
);
});
setActiveManualBackup(undefined);
}}
>
<Play className="size-5 text-muted-foreground" />
@@ -211,7 +168,6 @@ export const ShowBackups = ({ id, type }: Props) => {
<TooltipContent>Run Manual Backup</TooltipContent>
</Tooltip>
</TooltipProvider>
<UpdateBackup
backupId={backup.backupId}
refetch={refetch}

View File

@@ -47,7 +47,6 @@ 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>;
@@ -79,7 +78,6 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
},
resolver: zodResolver(UpdateBackupSchema),
});
@@ -92,9 +90,6 @@ 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]);
@@ -107,7 +102,6 @@ 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");
@@ -259,7 +253,7 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
<Input placeholder={"dokploy/"} {...field} />
</FormControl>
<FormDescription>
Use if you want to back up in a specific path of your
Use if you want to storage in a specific path of your
destination/bucket
</FormDescription>
@@ -268,29 +262,6 @@ 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

@@ -119,6 +119,7 @@ export const DockerLogsId: React.FC<Props> = ({
const wsUrl = `${protocol}//${
window.location.host
}/docker-container-logs?${params.toString()}`;
console.log("Connecting to WebSocket:", wsUrl);
const ws = new WebSocket(wsUrl);
const resetNoDataTimeout = () => {
@@ -135,6 +136,7 @@ export const DockerLogsId: React.FC<Props> = ({
ws.close();
return;
}
console.log("WebSocket connected");
resetNoDataTimeout();
};

View File

@@ -1,4 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
@@ -20,7 +19,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -108,20 +106,6 @@ 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/server"
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,15 +2,8 @@ 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 { api } from "@/utils/api";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
@@ -72,182 +65,99 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<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();
}}
<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"}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
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" ? (
<DialogAction
title="Reload Mariadb"
description="Are you sure you want to reload this mariadb?"
title="Start Mariadb"
description="Are you sure you want to start this mariadb?"
type="default"
onClick={async () => {
await reload({
await start({
mariadbId: mariadbId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mariadb reloaded successfully");
toast.success("Mariadb started successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mariadb");
toast.error("Error starting Mariadb");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MariaDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
</Button>
</DialogAction>
</TooltipProvider>
{data?.applicationStatus === "idle" ? (
<TooltipProvider delayDuration={0}>
<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 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</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>
</TooltipProvider>
) : (
<TooltipProvider delayDuration={0}>
<DialogAction
title="Stop Mariadb"
description="Are you sure you want to stop this mariadb?"
onClick={async () => {
await stop({
mariadbId: mariadbId,
<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();
})
.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 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MariaDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
</TooltipProvider>
.catch(() => {
toast.error("Error stopping Mariadb");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
</Button>
</DialogAction>
)}
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Terminal className="size-4 mr-1" />
Open Terminal
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Open a terminal to the MariaDB container</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
</CardContent>

View File

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

View File

@@ -1,4 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
@@ -20,7 +19,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -108,20 +106,6 @@ 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/server"
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,15 +2,8 @@ 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 { api } from "@/utils/api";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
@@ -71,176 +64,100 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<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();
}}
<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"}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
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" ? (
<DialogAction
title="Reload Mongo"
description="Are you sure you want to reload this mongo?"
title="Start Mongo"
description="Are you sure you want to start this mongo?"
type="default"
onClick={async () => {
await reload({
await start({
mongoId: mongoId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Mongo reloaded successfully");
toast.success("Mongo started successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Mongo");
toast.error("Error starting Mongo");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MongoDB service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
</Button>
</DialogAction>
{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,
) : (
<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();
})
.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 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</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 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MongoDB database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
.catch(() => {
toast.error("Error stopping Mongo");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
</Button>
</DialogAction>
)}
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Terminal className="size-4 mr-1" />
Open Terminal
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Open a terminal to the MongoDB container</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
</CardContent>

View File

@@ -121,7 +121,7 @@ export const UpdateMongo = ({ mongoId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Vandelay Industries" {...field} />
<Input placeholder="Tesla" {...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,4 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
@@ -20,7 +19,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -108,20 +106,6 @@ 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/server"
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,15 +2,8 @@ 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 { api } from "@/utils/api";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Ban, CheckCircle2, RefreshCcw, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
@@ -69,176 +62,100 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<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();
}}
<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"}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
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" ? (
<DialogAction
title="Reload MySQL"
description="Are you sure you want to reload this mysql?"
title="Start Mysql"
description="Are you sure you want to start this mysql?"
type="default"
onClick={async () => {
await reload({
await start({
mysqlId: mysqlId,
appName: data?.appName || "",
})
.then(() => {
toast.success("MySQL reloaded successfully");
toast.success("Mysql started successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading MySQL");
toast.error("Error starting Mysql");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the MySQL service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
</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,
) : (
<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();
})
.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 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</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 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running MySQL database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
.catch(() => {
toast.error("Error stopping Mysql");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
</Button>
</DialogAction>
)}
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Terminal className="size-4 mr-1" />
Open Terminal
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Open a terminal to the MySQL container</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
</CardContent>

View File

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

View File

@@ -1,4 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
@@ -20,7 +19,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -110,20 +108,6 @@ 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/server"
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,20 +2,12 @@ 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 { api } from "@/utils/api";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Ban, CheckCircle2, 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;
}
@@ -65,198 +57,122 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
);
return (
<>
<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 disableHoverableContent={false}>
<DialogAction
title="Deploy PostgreSQL"
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"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</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 PostgreSQL"
description="Are you sure you want to reload this postgres?"
type="default"
onClick={async () => {
await reload({
postgresId: postgresId,
appName: data?.appName || "",
})
.then(() => {
toast.success("PostgreSQL reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading PostgreSQL");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</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 PostgreSQL"
description="Are you sure you want to start this postgres?"
type="default"
onClick={async () => {
await start({
postgresId: postgresId,
})
.then(() => {
toast.success("PostgreSQL started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting PostgreSQL");
});
}}
>
<Button
variant="secondary"
isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</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 PostgreSQL"
description="Are you sure you want to stop this postgres?"
onClick={async () => {
await stop({
postgresId: postgresId,
})
.then(() => {
toast.success("PostgreSQL stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping PostgreSQL");
});
}}
>
<Button
variant="destructive"
isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</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 || ""}
<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"}
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Terminal className="size-4 mr-1" />
Open Terminal
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Open a terminal to the PostgreSQL container</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
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");
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();
})
.catch(() => {
toast.error("Error starting Postgres");
});
}}
>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
</Button>
</DockerTerminalModal>
</CardContent>
</Card>
<DrawerLogs
isOpen={isDrawerOpen}
onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>
</div>
</>
</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>
);
};

View File

@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBox } from "lucide-react";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -94,9 +94,9 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 focus-visible:ring-2 focus-visible:ring-offset-2"
className="group hover:bg-blue-500/10 "
>
<PenBox className="size-3.5 text-primary group-hover:text-blue-500" />
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
@@ -121,7 +121,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Vandelay Industries" {...field} />
<Input placeholder="Tesla" {...field} />
</FormControl>
<FormMessage />
@@ -151,7 +151,6 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
isLoading={isLoading}
form="hook-form-update-postgres"
type="submit"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
Update
</Button>

View File

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

View File

@@ -1,4 +1,3 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import {
AlertDialog,
@@ -58,67 +57,32 @@ import {
BookText,
CheckIcon,
ChevronsUpDown,
Github,
Globe,
HelpCircle,
LayoutGrid,
List,
Loader2,
PuzzleIcon,
SearchIcon,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useState } 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, baseUrl }: Props) => {
export const AddTemplate = ({ projectId }: Props) => {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
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 } = api.compose.templates.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: tags, isLoading: isLoadingTags } = api.compose.getTags.useQuery(
{ baseUrl: customBaseUrl },
{
enabled: open,
},
);
const { data: tags, isLoading: isLoadingTags } =
api.compose.getTags.useQuery();
const utils = api.useUtils();
const [serverId, setServerId] = useState<string | undefined>(undefined);
@@ -165,14 +129,6 @@ export const AddTemplate = ({ projectId, baseUrl }: 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
@@ -276,20 +232,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
</AlertBlock>
)}
{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 ? (
{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">
@@ -305,9 +248,9 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6",
)}
>
{templates?.map((template) => (
{templates?.map((template, index) => (
<div
key={template?.id}
key={`template-${index}`}
className={cn(
"flex flex-col border rounded-lg overflow-hidden relative",
viewMode === "icon" && "h-[200px]",
@@ -315,8 +258,9 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
)}
>
<Badge className="absolute top-2 right-2" variant="blue">
{template?.version}
{template.version}
</Badge>
{/* Template Header */}
<div
className={cn(
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
@@ -324,21 +268,21 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
)}
>
<img
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
src={`/templates/${template.logo}`}
className={cn(
"object-contain",
viewMode === "detailed" ? "size-24" : "size-16",
)}
alt={template?.name}
alt={template.name}
/>
<div className="flex flex-col items-center gap-2">
<span className="text-sm font-medium line-clamp-1">
{template?.name}
{template.name}
</span>
{viewMode === "detailed" &&
template?.tags?.length > 0 && (
template.tags.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5">
{template?.tags?.map((tag) => (
{template.tags.map((tag) => (
<Badge
key={tag}
variant="green"
@@ -356,7 +300,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
{viewMode === "detailed" && (
<ScrollArea className="flex-1 p-6">
<div className="text-sm text-muted-foreground">
{template?.description}
{template.description}
</div>
</ScrollArea>
)}
@@ -372,27 +316,25 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
>
{viewMode === "detailed" && (
<div className="flex gap-2">
{template?.links?.github && (
<Link
href={template.links.github}
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Github className="size-5" />
</Link>
{template.links.website && (
<Link
href={template?.links?.github}
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<GithubIcon className="size-5" />
</Link>
)}
{template?.links?.website && (
<Link
href={template?.links?.website}
href={template.links.website}
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<Globe className="size-5" />
</Link>
)}
{template?.links?.docs && (
{template.links.docs && (
<Link
href={template?.links?.docs}
href={template.links.docs}
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
>
@@ -421,7 +363,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
</AlertDialogTitle>
<AlertDialogDescription>
This will create an application from the{" "}
{template?.name} template and add it to your
{template.name} template and add it to your
project.
</AlertDialogDescription>
@@ -441,9 +383,8 @@ export const AddTemplate = ({ projectId, baseUrl }: 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>
@@ -489,19 +430,18 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
projectId,
serverId: serverId || undefined,
id: template.id,
baseUrl: customBaseUrl,
});
toast.promise(promise, {
loading: "Setting up...",
success: () => {
success: (_data) => {
utils.project.one.invalidate({
projectId,
});
setOpen(false);
return `${template.name} template created successfully`;
},
error: () => {
return `An error occurred deploying ${template.name} template`;
error: (_err) => {
return `An error ocurred deploying ${template.name} template`;
},
});
}}

View File

@@ -13,6 +13,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { useState } from "react";
const examples = [
"Make a personal blog",
@@ -22,7 +23,7 @@ const examples = [
"Sendgrid service opensource analogue",
];
export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
export const StepOne = ({ nextStep, setTemplateInfo, templateInfo }: any) => {
// Get servers from the API
const { data: servers } = api.server.withSSHKey.useQuery();

View File

@@ -1,172 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { Copy, Loader2 } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { toast } from "sonner";
export type Services = {
appName: string;
serverId?: string | null;
name: string;
type:
| "mariadb"
| "application"
| "postgres"
| "mysql"
| "mongo"
| "redis"
| "compose";
description?: string | null;
id: string;
createdAt: string;
status?: "idle" | "running" | "done" | "error";
};
interface DuplicateProjectProps {
projectId: string;
services: Services[];
selectedServiceIds: string[];
}
export const DuplicateProject = ({
projectId,
services,
selectedServiceIds,
}: DuplicateProjectProps) => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const utils = api.useUtils();
const router = useRouter();
const selectedServices = services.filter((service) =>
selectedServiceIds.includes(service.id),
);
const { mutateAsync: duplicateProject, isLoading } =
api.project.duplicate.useMutation({
onSuccess: async (newProject) => {
await utils.project.all.invalidate();
toast.success("Project duplicated successfully");
setOpen(false);
router.push(`/dashboard/project/${newProject.projectId}`);
},
onError: (error) => {
toast.error(error.message);
},
});
const handleDuplicate = async () => {
if (!name) {
toast.error("Project name is required");
return;
}
await duplicateProject({
sourceProjectId: projectId,
name,
description,
includeServices: true,
selectedServices: selectedServices.map((service) => ({
id: service.id,
type: service.type,
})),
});
};
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
// Reset form when closing
setName("");
setDescription("");
}
}}
>
<DialogTrigger asChild>
<Button variant="ghost" className="w-full justify-start">
<Copy className="mr-2 h-4 w-4" />
Duplicate
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate Project</DialogTitle>
<DialogDescription>
Create a new project with the selected services
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New project name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Project description (optional)"
/>
</div>
<div className="grid gap-2">
<Label>Selected services to duplicate</Label>
<div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
{selectedServices.map((service) => (
<div key={service.id} className="flex items-center space-x-2">
<span className="text-sm">
{service.name} ({service.type})
</span>
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button onClick={handleDuplicate} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Duplicating...
</>
) : (
"Duplicate"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -31,14 +31,9 @@ import { toast } from "sonner";
import { z } from "zod";
const AddProjectSchema = z.object({
name: z
.string()
.min(1, {
message: "Name is required",
})
.regex(/^[a-zA-Z]/, {
message: "Project name cannot start with a number",
}),
name: z.string().min(1, {
message: "Name is required",
}),
description: z.string().optional(),
});
@@ -102,6 +97,18 @@ export const HandleProject = ({ projectId }: Props) => {
);
});
};
// useEffect(() => {
// const getUsers = async () => {
// const users = await authClient.admin.listUsers({
// query: {
// limit: 100,
// },
// });
// console.log(users);
// };
// getUsers();
// });
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -141,7 +148,7 @@ export const HandleProject = ({ projectId }: Props) => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Vandelay Industries" {...field} />
<Input placeholder="Tesla" {...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-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 flex-wrap gap-5">
{filteredProjects?.map((project) => {
const emptyServices =
project?.mariadb.length === 0 &&
@@ -186,9 +186,7 @@ export const ShowProjects = () => {
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span className="truncate">
{domain.host}
</span>
<span>{domain.host}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
@@ -224,9 +222,7 @@ export const ShowProjects = () => {
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span className="truncate">
{domain.host}
</span>
<span>{domain.host}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>

View File

@@ -1,4 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { Button } from "@/components/ui/button";
import {
@@ -20,7 +19,6 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -102,20 +100,6 @@ 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/server"
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,20 +2,12 @@ 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 { api } from "@/utils/api";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Ban, CheckCircle2, 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;
}
@@ -71,176 +63,101 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}>
<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();
}}
<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"}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Rocket className="size-4 mr-1" />
Deploy
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Downloads and sets up the Redis database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
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" ? (
<DialogAction
title="Reload Redis"
description="Are you sure you want to reload this redis?"
title="Start Redis"
description="Are you sure you want to start this redis?"
type="default"
onClick={async () => {
await reload({
await start({
redisId: redisId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Redis reloaded successfully");
toast.success("Redis started successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading Redis");
toast.error("Error starting Redis");
});
}}
>
<Button
variant="secondary"
isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Restart the Redis service without rebuilding</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
</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,
) : (
<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();
})
.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 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<CheckCircle2 className="size-4 mr-1" />
Start
</div>
</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 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Ban className="size-4 mr-1" />
Stop
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Stop the currently running Redis database</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</Button>
</DialogAction>
)}
</TooltipProvider>
.catch(() => {
toast.error("Error stopping Redis");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
</Button>
</DialogAction>
)}
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Terminal className="size-4 mr-1" />
Open Terminal
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]">
<p>Open a terminal to the Redis container</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
<Button variant="outline">
<Terminal />
Open Terminal
</Button>
</DockerTerminalModal>
</CardContent>

View File

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

View File

@@ -25,13 +25,13 @@ import {
import { type RouterOutputs, api } from "@/utils/api";
import { format } from "date-fns";
import {
AlertCircle,
ArrowDownUp,
Calendar as CalendarIcon,
AlertCircle,
InfoIcon,
Calendar as CalendarIcon,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { RequestDistributionChart } from "./request-distribution-chart";
import { RequestsTable } from "./requests-table";

View File

@@ -1,22 +1,14 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -25,14 +17,20 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
@@ -443,16 +441,13 @@ export const AddApiKey = () => {
</DialogDescription>
</DialogHeader>
<div className="mt-4 space-y-4">
<CodeEditor
className="font-mono text-sm break-all"
language="properties"
value={newApiKey}
readOnly
/>
<div className="rounded-md bg-muted p-4 font-mono text-sm break-all">
{newApiKey}
</div>
<div className="flex justify-end gap-3">
<Button
onClick={() => {
copy(newApiKey);
navigator.clipboard.writeText(newApiKey);
toast.success("API key copied to clipboard");
}}
>

View File

@@ -1,5 +1,3 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -9,11 +7,13 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { formatDistanceToNow } from "date-fns";
import { Clock, ExternalLinkIcon, KeyIcon, Tag, Trash2 } from "lucide-react";
import { ExternalLinkIcon, KeyIcon, Trash2, Clock, Tag } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { formatDistanceToNow } from "date-fns";
import { DialogAction } from "@/components/shared/dialog-action";
import { AddApiKey } from "./add-api-key";
import { Badge } from "@/components/ui/badge";
export const ShowApiKeys = () => {
const { data, refetch } = api.user.get.useQuery();

View File

@@ -1,4 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -14,11 +13,7 @@ import Link from "next/link";
import { AddManager } from "./manager/add-manager";
import { AddWorker } from "./workers/add-worker";
interface Props {
serverId?: string;
}
export const AddNode = ({ serverId }: Props) => {
export const AddNode = () => {
return (
<Dialog>
<DialogTrigger asChild>
@@ -49,10 +44,6 @@ export const AddNode = ({ serverId }: Props) => {
Architecture
<ExternalLink className="h-4 w-4" />
</Link>
<AlertBlock type="warning">
Make sure you use the same architecture as the node you are
adding.
</AlertBlock>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
@@ -61,11 +52,11 @@ export const AddNode = ({ serverId }: Props) => {
<TabsTrigger value="worker">Worker</TabsTrigger>
<TabsTrigger value="manager">Manager</TabsTrigger>
</TabsList>
<TabsContent value="worker" className="pt-4 overflow-hidden">
<AddWorker serverId={serverId} />
<TabsContent value="worker" className="pt-4">
<AddWorker />
</TabsContent>
<TabsContent value="manager" className="pt-4 overflow-hidden">
<AddManager serverId={serverId} />
<TabsContent value="manager" className="pt-4">
<AddManager />
</TabsContent>
</Tabs>
</div>

View File

@@ -1,4 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CardContent } from "@/components/ui/card";
import {
DialogDescription,
@@ -7,74 +6,60 @@ import {
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { CopyIcon, Loader2 } from "lucide-react";
import { CopyIcon } from "lucide-react";
import { toast } from "sonner";
interface Props {
serverId?: string;
}
export const AddManager = ({ serverId }: Props) => {
const { data, isLoading, error, isError } = api.cluster.addManager.useQuery({
serverId,
});
export const AddManager = () => {
const { data } = api.cluster.addManager.useQuery();
return (
<>
<CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
<DialogHeader>
<DialogTitle>Add a new manager</DialogTitle>
<DialogDescription>Add a new manager</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{isLoading ? (
<Loader2 className="w-full animate-spin text-muted-foreground" />
) : (
<>
<div className="flex flex-col gap-2.5 text-sm">
<span>
1. Go to your new server and run the following command
</span>
<span className="bg-muted rounded-lg p-2 flex justify-between">
curl https://get.docker.com | sh -s -- --version {data?.version}
<button
type="button"
className="self-center"
onClick={() => {
copy(
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
);
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
<div>
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0">
<DialogHeader>
<DialogTitle>Add a new manager</DialogTitle>
<DialogDescription>Add a new manager</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2.5 text-sm">
<span>1. Go to your new server and run the following command</span>
<span className="bg-muted rounded-lg p-2 flex justify-between">
curl https://get.docker.com | sh -s -- --version {data?.version}
<button
type="button"
className="self-center"
onClick={() => {
copy(
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
);
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
<div className="flex flex-col gap-2.5 text-sm">
<span>
2. Run the following command to add the node(manager) to your
cluster
</span>
<span className="bg-muted rounded-lg p-2 flex">
{data?.command}
<button
type="button"
className="self-start"
onClick={() => {
copy(data?.command || "");
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
</>
)}
</CardContent>
<div className="flex flex-col gap-2.5 text-sm">
<span>
2. Run the following command to add the node(manager) to your
cluster
</span>
<span className="bg-muted rounded-lg p-2 flex">
{data?.command}
<button
type="button"
className="self-start"
onClick={() => {
copy(data?.command || "");
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
</CardContent>
</div>
</>
);
};

View File

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

View File

@@ -1,4 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CardContent } from "@/components/ui/card";
import {
DialogDescription,
@@ -7,70 +6,58 @@ import {
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { CopyIcon, Loader2 } from "lucide-react";
import { CopyIcon } from "lucide-react";
import { toast } from "sonner";
interface Props {
serverId?: string;
}
export const AddWorker = ({ serverId }: Props) => {
const { data, isLoading, error, isError } = api.cluster.addWorker.useQuery({
serverId,
});
export const AddWorker = () => {
const { data } = api.cluster.addWorker.useQuery();
return (
<CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
<DialogHeader>
<DialogTitle>Add a new worker</DialogTitle>
<DialogDescription>Add a new worker</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{isLoading ? (
<Loader2 className="w-full animate-spin text-muted-foreground" />
) : (
<>
<div className="flex flex-col gap-2.5 text-sm">
<span>1. Go to your new server and run the following command</span>
<span className="bg-muted rounded-lg p-2 flex justify-between">
curl https://get.docker.com | sh -s -- --version {data?.version}
<button
type="button"
className="self-center"
onClick={() => {
copy(
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
);
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
<div>
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0">
<DialogHeader>
<DialogTitle>Add a new worker</DialogTitle>
<DialogDescription>Add a new worker</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2.5 text-sm">
<span>1. Go to your new server and run the following command</span>
<span className="bg-muted rounded-lg p-2 flex justify-between">
curl https://get.docker.com | sh -s -- --version {data?.version}
<button
type="button"
className="self-center"
onClick={() => {
copy(
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
);
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
<div className="flex flex-col gap-2.5 text-sm">
<span>
2. Run the following command to add the node(worker) to your
cluster
</span>
<div className="flex flex-col gap-2.5 text-sm">
<span>
2. Run the following command to add the node(worker) to your cluster
</span>
<span className="bg-muted rounded-lg p-2 flex">
{data?.command}
<button
type="button"
className="self-start"
onClick={() => {
copy(data?.command || "");
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
</>
)}
</CardContent>
<span className="bg-muted rounded-lg p-2 flex">
{data?.command}
<button
type="button"
className="self-start"
onClick={() => {
copy(data?.command || "");
toast.success("Copied to clipboard");
}}
>
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
</CardContent>
</div>
);
};

View File

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

View File

@@ -1,286 +0,0 @@
import { GiteaIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
type GiteaProviderResponse,
getGiteaOAuthUrl,
} from "@/utils/gitea-utils";
import { useUrl } from "@/utils/hooks/use-url";
import { zodResolver } from "@hookform/resolvers/zod";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
giteaUrl: z.string().min(1, {
message: "Gitea URL is required",
}),
clientId: z.string().min(1, {
message: "Client ID is required",
}),
clientSecret: z.string().min(1, {
message: "Client Secret is required",
}),
redirectUri: z.string().min(1, {
message: "Redirect URI is required",
}),
organizationName: z.string().optional(),
});
type Schema = z.infer<typeof Schema>;
export const AddGiteaProvider = () => {
const [isOpen, setIsOpen] = useState(false);
const urlObj = useUrl();
const baseUrl =
typeof urlObj === "string" ? urlObj : (urlObj as any)?.url || "";
const { mutateAsync, error, isError } = api.gitea.create.useMutation();
const webhookUrl = `${baseUrl}/api/providers/gitea/callback`;
const form = useForm<Schema>({
defaultValues: {
clientId: "",
clientSecret: "",
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
},
resolver: zodResolver(Schema),
});
const giteaUrl = form.watch("giteaUrl");
useEffect(() => {
form.reset({
clientId: "",
clientSecret: "",
redirectUri: webhookUrl,
name: "",
giteaUrl: "https://gitea.com",
});
}, [form, webhookUrl, isOpen]);
const onSubmit = async (data: Schema) => {
try {
// Send the form data to create the Gitea provider
const result = (await mutateAsync({
clientId: data.clientId,
clientSecret: data.clientSecret,
name: data.name,
redirectUri: data.redirectUri,
giteaUrl: data.giteaUrl,
organizationName: data.organizationName,
})) as unknown as GiteaProviderResponse;
// Check if we have a giteaId from the response
if (!result || !result.giteaId) {
toast.error("Failed to get Gitea ID from response");
return;
}
// Generate OAuth URL using the shared utility
const authUrl = getGiteaOAuthUrl(
result.giteaId,
data.clientId,
data.giteaUrl,
baseUrl,
);
// Open the Gitea OAuth URL
if (authUrl !== "#") {
window.open(authUrl, "_blank");
} else {
toast.error("Configuration Incomplete", {
description: "Please fill in Client ID and Gitea URL first.",
});
}
toast.success("Gitea provider created successfully");
setIsOpen(false);
} catch (error: unknown) {
if (error instanceof Error) {
toast.error(`Error configuring Gitea: ${error.message}`);
} else {
toast.error("An unknown error occurred.");
}
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="default"
className="flex items-center space-x-1 bg-green-700 text-white hover:bg-green-500"
>
<GiteaIcon />
<span>Gitea</span>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl overflow-y-auto max-h-screen">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Gitea Provider <GiteaIcon className="size-5" />
</DialogTitle>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-add-gitea"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-1"
>
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
To integrate your Gitea account, you need to create a new
application in your Gitea settings. Follow these steps:
</p>
<ol className="list-decimal list-inside text-sm text-muted-foreground">
<li className="flex flex-row gap-2 items-center">
Go to your Gitea settings{" "}
<Link
href={`${giteaUrl}/user/settings/applications`}
target="_blank"
>
<ExternalLink className="w-fit text-primary size-4" />
</Link>
</li>
<li>
Navigate to Applications {"->"} Create new OAuth2
Application
</li>
<li>
Create a new application with the following details:
<ul className="list-disc list-inside ml-4">
<li>Name: Dokploy</li>
<li>
Redirect URI:{" "}
<span className="text-primary">{webhookUrl}</span>{" "}
</li>
</ul>
</li>
<li>
After creating, you'll receive an ID and Secret, copy them
and paste them below.
</li>
</ol>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Random Name eg(my-personal-account)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="giteaUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Gitea URL</FormLabel>
<FormControl>
<Input placeholder="https://gitea.com/" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="redirectUri"
render={({ field }) => (
<FormItem>
<FormLabel>Redirect URI</FormLabel>
<FormControl>
<Input
disabled
placeholder="Random Name eg(my-personal-account)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input placeholder="Client ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Client Secret</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Client Secret"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button isLoading={form.formState.isSubmitting}>
Configure Gitea App
</Button>
</div>
</CardContent>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,296 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { getGiteaOAuthUrl } from "@/utils/gitea-utils";
import { useUrl } from "@/utils/hooks/use-url";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
giteaUrl: z.string().min(1, "Gitea URL is required"),
clientId: z.string().min(1, "Client ID is required"),
clientSecret: z.string().min(1, "Client Secret is required"),
});
interface Props {
giteaId: string;
}
export const EditGiteaProvider = ({ giteaId }: Props) => {
const router = useRouter();
const [open, setOpen] = useState(false);
const {
data: gitea,
isLoading,
refetch,
} = api.gitea.one.useQuery({ giteaId });
const { mutateAsync, isLoading: isUpdating } = api.gitea.update.useMutation();
const { mutateAsync: testConnection, isLoading: isTesting } =
api.gitea.testConnection.useMutation();
const url = useUrl();
const utils = api.useUtils();
useEffect(() => {
const { connected, error } = router.query;
if (!router.isReady) return;
if (connected) {
toast.success("Successfully connected to Gitea", {
description: "Your Gitea provider has been authorized.",
id: "gitea-connection-success",
});
refetch();
router.replace(
{
pathname: router.pathname,
query: {},
},
undefined,
{ shallow: true },
);
}
if (error) {
toast.error("Gitea Connection Failed", {
description: decodeURIComponent(error as string),
id: "gitea-connection-error",
});
router.replace(
{
pathname: router.pathname,
query: {},
},
undefined,
{ shallow: true },
);
}
}, [router.query, router.isReady, refetch]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
giteaUrl: "https://gitea.com",
clientId: "",
clientSecret: "",
},
});
useEffect(() => {
if (gitea) {
form.reset({
name: gitea.gitProvider?.name || "",
giteaUrl: gitea.giteaUrl || "https://gitea.com",
clientId: gitea.clientId || "",
clientSecret: gitea.clientSecret || "",
});
}
}, [gitea, form]);
const onSubmit = async (values: z.infer<typeof formSchema>) => {
await mutateAsync({
giteaId: giteaId,
gitProviderId: gitea?.gitProvider?.gitProviderId || "",
name: values.name,
giteaUrl: values.giteaUrl,
clientId: values.clientId,
clientSecret: values.clientSecret,
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
toast.success("Gitea provider updated successfully");
await refetch();
setOpen(false);
})
.catch(() => {
toast.error("Error updating Gitea provider");
});
};
const handleTestConnection = async () => {
try {
const result = await testConnection({ giteaId });
toast.success("Gitea Connection Verified", {
description: result,
});
} catch (error: any) {
const formValues = form.getValues();
const authUrl =
error.authorizationUrl ||
getGiteaOAuthUrl(
giteaId,
formValues.clientId,
formValues.giteaUrl,
typeof url === "string" ? url : (url as any).url || "",
);
toast.error("Gitea Not Connected", {
description:
error.message || "Please complete the OAuth authorization process.",
action:
authUrl && authUrl !== "#"
? {
label: "Authorize Now",
onClick: () => window.open(authUrl, "_blank"),
}
: undefined,
});
}
};
if (isLoading) {
return (
<Button variant="ghost" size="icon" disabled>
<PenBoxIcon className="h-4 w-4 text-muted-foreground" />
</Button>
);
}
// Function to handle dialog open state
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Gitea Provider</DialogTitle>
<DialogDescription>
Update your Gitea provider details.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="My Gitea"
{...field}
autoFocus={false}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="giteaUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Gitea URL</FormLabel>
<FormControl>
<Input placeholder="https://gitea.example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormControl>
<Input placeholder="Client ID" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Client Secret</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Client Secret"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
isLoading={isTesting}
>
Test Connection
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
const formValues = form.getValues();
const authUrl = getGiteaOAuthUrl(
giteaId,
formValues.clientId,
formValues.giteaUrl,
typeof url === "string" ? url : (url as any).url || "",
);
if (authUrl !== "#") {
window.open(authUrl, "_blank");
}
}}
>
Connect to Gitea
</Button>
<Button type="submit" isLoading={isUpdating}>
Save
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -248,9 +248,7 @@ export const AddGitlabProvider = () => {
name="groupName"
render={({ field }) => (
<FormItem>
<FormLabel>
Group Name (Optional, Comma-Separated List)
</FormLabel>
<FormLabel>Group Name (Optional)</FormLabel>
<FormControl>
<Input
placeholder="For organization/group access use the slugish name of the group eg: my-org"

View File

@@ -156,9 +156,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
name="groupName"
render={({ field }) => (
<FormItem>
<FormLabel>
Group Name (Optional, Comma-Separated List)
</FormLabel>
<FormLabel>Group Name (Optional)</FormLabel>
<FormControl>
<Input
placeholder="For organization/group access use the slugish name of the group eg: my-org"

View File

@@ -1,6 +1,5 @@
import {
BitbucketIcon,
GiteaIcon,
GithubIcon,
GitlabIcon,
} from "@/components/icons/data-tools-icons";
@@ -27,8 +26,6 @@ import Link from "next/link";
import { toast } from "sonner";
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
import { AddGiteaProvider } from "./gitea/add-gitea-provider";
import { EditGiteaProvider } from "./gitea/edit-gitea-provider";
import { AddGithubProvider } from "./github/add-github-provider";
import { EditGithubProvider } from "./github/edit-github-provider";
import { AddGitlabProvider } from "./gitlab/add-gitlab-provider";
@@ -39,18 +36,19 @@ export const ShowGitProviders = () => {
const { mutateAsync, isLoading: isRemoving } =
api.gitProvider.remove.useMutation();
const url = useUrl();
const getGitlabUrl = (
clientId: string,
gitlabId: string,
gitlabUrl: string,
) => {
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
const scope = "api read_user read_repository";
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
return authUrl;
};
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
@@ -84,7 +82,6 @@ export const ShowGitProviders = () => {
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
<AddGiteaProvider />
</div>
</div>
</div>
@@ -100,7 +97,6 @@ export const ShowGitProviders = () => {
<AddGithubProvider />
<AddGitlabProvider />
<AddBitbucketProvider />
<AddGiteaProvider />
</div>
</div>
</div>
@@ -111,16 +107,13 @@ export const ShowGitProviders = () => {
const isGitlab = gitProvider.providerType === "gitlab";
const isBitbucket =
gitProvider.providerType === "bitbucket";
const isGitea = gitProvider.providerType === "gitea";
const haveGithubRequirements =
isGithub &&
gitProvider.providerType === "github" &&
gitProvider.github?.githubPrivateKey &&
gitProvider.github?.githubAppId &&
gitProvider.github?.githubInstallationId;
const haveGitlabRequirements =
isGitlab &&
gitProvider.gitlab?.accessToken &&
gitProvider.gitlab?.refreshToken;
@@ -129,19 +122,18 @@ export const ShowGitProviders = () => {
key={gitProvider.gitProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
>
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col items-center justify-between">
<div className="flex gap-2 flex-row items-center">
{isGithub && (
{gitProvider.providerType === "github" && (
<GithubIcon className="size-5" />
)}
{isGitlab && (
{gitProvider.providerType === "gitlab" && (
<GitlabIcon className="size-5" />
)}
{isBitbucket && (
{gitProvider.providerType === "bitbucket" && (
<BitbucketIcon className="size-5" />
)}
{isGitea && <GiteaIcon className="size-5" />}
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{gitProvider.name}
@@ -202,33 +194,26 @@ export const ShowGitProviders = () => {
</Link>
</div>
)}
{isGithub && haveGithubRequirements && (
<EditGithubProvider
githubId={gitProvider.github?.githubId}
githubId={gitProvider.github.githubId}
/>
)}
{isGitlab && (
<EditGitlabProvider
gitlabId={gitProvider.gitlab?.gitlabId}
gitlabId={gitProvider.gitlab.gitlabId}
/>
)}
{isBitbucket && (
<EditBitbucketProvider
bitbucketId={
gitProvider.bitbucket?.bitbucketId
gitProvider.bitbucket.bitbucketId
}
/>
)}
{isGitea && (
<EditGiteaProvider
giteaId={gitProvider.gitea?.giteaId}
/>
)}
<DialogAction
title="Delete Git Provider"
description="Are you sure you want to delete this Git Provider?"
@@ -253,7 +238,7 @@ export const ShowGitProviders = () => {
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />

View File

@@ -663,16 +663,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
{...field}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(undefined);
} else {
if (value) {
const port = Number.parseInt(value);
if (port > 0 && port < 65536) {
field.onChange(port);
}
}
}}
value={field.value || ""}
type="number"
/>
</FormControl>

View File

@@ -36,7 +36,6 @@ const PasswordSchema = z.object({
password: z.string().min(8, {
message: "Password is required",
}),
issuer: z.string().optional(),
});
const PinSchema = z.object({
@@ -61,90 +60,16 @@ export const Enable2FA = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [step, setStep] = useState<"password" | "verify">("password");
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const [otpValue, setOtpValue] = useState("");
const handleVerifySubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const result = await authClient.twoFactor.verifyTotp({
code: otpValue,
});
if (result.error) {
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
toast.error("Invalid verification code");
return;
}
throw result.error;
}
if (!result.data) {
throw new Error("No response received from server");
}
toast.success("2FA configured successfully");
utils.user.get.invalidate();
setIsDialogOpen(false);
} catch (error) {
if (error instanceof Error) {
const errorMessage =
error.message === "Failed to fetch"
? "Connection error. Please check your internet connection."
: error.message;
toast.error(errorMessage);
} else {
toast.error("Error verifying 2FA code", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
}
};
const passwordForm = useForm<PasswordForm>({
resolver: zodResolver(PasswordSchema),
defaultValues: {
password: "",
},
});
const pinForm = useForm<PinForm>({
resolver: zodResolver(PinSchema),
defaultValues: {
pin: "",
},
});
useEffect(() => {
if (!isDialogOpen) {
setStep("password");
setData(null);
setBackupCodes([]);
setOtpValue("");
passwordForm.reset({
password: "",
issuer: "",
});
}
}, [isDialogOpen, passwordForm]);
useEffect(() => {
if (step === "verify") {
setOtpValue("");
}
}, [step]);
const handlePasswordSubmit = async (formData: PasswordForm) => {
setIsPasswordLoading(true);
try {
const { data: enableData, error } = await authClient.twoFactor.enable({
const { data: enableData } = await authClient.twoFactor.enable({
password: formData.password,
issuer: formData.issuer,
});
if (!enableData) {
throw new Error(error?.message || "Error enabling 2FA");
throw new Error("No data received from server");
}
if (enableData.backupCodes) {
@@ -170,14 +95,82 @@ export const Enable2FA = () => {
error instanceof Error ? error.message : "Error setting up 2FA",
);
passwordForm.setError("password", {
message:
error instanceof Error ? error.message : "Error setting up 2FA",
message: "Error verifying password",
});
} finally {
setIsPasswordLoading(false);
}
};
const handleVerifySubmit = async (formData: PinForm) => {
try {
const result = await authClient.twoFactor.verifyTotp({
code: formData.pin,
});
if (result.error) {
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
pinForm.setError("pin", {
message: "Invalid code. Please try again.",
});
toast.error("Invalid verification code");
return;
}
throw result.error;
}
if (!result.data) {
throw new Error("No response received from server");
}
toast.success("2FA configured successfully");
utils.user.get.invalidate();
setIsDialogOpen(false);
} catch (error) {
if (error instanceof Error) {
const errorMessage =
error.message === "Failed to fetch"
? "Connection error. Please check your internet connection."
: error.message;
pinForm.setError("pin", {
message: errorMessage,
});
toast.error(errorMessage);
} else {
pinForm.setError("pin", {
message: "Error verifying code",
});
toast.error("Error verifying 2FA code");
}
}
};
const passwordForm = useForm<PasswordForm>({
resolver: zodResolver(PasswordSchema),
defaultValues: {
password: "",
},
});
const pinForm = useForm<PinForm>({
resolver: zodResolver(PinSchema),
defaultValues: {
pin: "",
},
});
useEffect(() => {
if (!isDialogOpen) {
setStep("password");
setData(null);
setBackupCodes([]);
passwordForm.reset();
pinForm.reset();
}
}, [isDialogOpen, passwordForm, pinForm]);
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
@@ -223,27 +216,6 @@ export const Enable2FA = () => {
</FormItem>
)}
/>
<FormField
control={passwordForm.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>Issuer</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Enter your issuer"
{...field}
/>
</FormControl>
<FormDescription>
Use a custom issuer to identify the service you're
authenticating with.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
@@ -255,7 +227,11 @@ export const Enable2FA = () => {
</Form>
) : (
<Form {...pinForm}>
<form onSubmit={handleVerifySubmit} className="space-y-6">
<form
id="pin-form"
onSubmit={pinForm.handleSubmit(handleVerifySubmit)}
className="space-y-6"
>
<div className="flex flex-col gap-6 justify-center items-center">
{data?.qrCodeUrl ? (
<>
@@ -307,33 +283,36 @@ export const Enable2FA = () => {
)}
</div>
<div className="flex flex-col justify-center items-center">
<FormLabel>Verification Code</FormLabel>
<InputOTP
maxLength={6}
value={otpValue}
onChange={setOtpValue}
autoComplete="off"
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<FormDescription>
Enter the 6-digit code from your authenticator app
</FormDescription>
</div>
<FormField
control={pinForm.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col justify-center items-center">
<FormLabel>Verification Code</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
Enter the 6-digit code from your authenticator app
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
isLoading={isPasswordLoading}
disabled={otpValue.length !== 6}
>
Enable 2FA
</Button>

View File

@@ -56,7 +56,6 @@ const randomImages = [
export const ProfileForm = () => {
const _utils = api.useUtils();
const { data, refetch, isLoading } = api.user.get.useQuery();
const {
mutateAsync,
isLoading: isUpdating,
@@ -85,17 +84,12 @@ export const ProfileForm = () => {
useEffect(() => {
if (data) {
form.reset(
{
email: data?.user?.email || "",
password: form.getValues("password") || "",
image: data?.user?.image || "",
currentPassword: form.getValues("currentPassword") || "",
},
{
keepValues: true,
},
);
form.reset({
email: data?.user?.email || "",
password: "",
image: data?.user?.image || "",
currentPassword: "",
});
if (data.user.email) {
generateSHA256Hash(data.user.email).then((hash) => {
@@ -103,7 +97,8 @@ export const ProfileForm = () => {
});
}
}
}, [form, data]);
form.reset();
}, [form, form.reset, data]);
const onSubmit = async (values: Profile) => {
await mutateAsync({
@@ -115,12 +110,7 @@ export const ProfileForm = () => {
.then(async () => {
await refetch();
toast.success("Profile Updated");
form.reset({
email: values.email,
password: "",
image: values.image,
currentPassword: "",
});
form.reset();
})
.catch(() => {
toast.error("Error updating the profile");

View File

@@ -59,17 +59,15 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
.then(async () => {
toast.success("Traefik Reloaded");
})
.catch(() => {});
.catch(() => {
toast.error("Error reloading Traefik");
});
}}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.reload")}</span>
</DropdownMenuItem>
<ShowModalLogs
appName="dokploy-traefik"
serverId={serverId}
type="standalone"
>
<ShowModalLogs appName="dokploy-traefik" serverId={serverId}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
@@ -110,6 +108,15 @@ 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

@@ -25,7 +25,7 @@ export const SecurityAudit = ({ serverId }: Props) => {
enabled: !!serverId,
},
);
const _utils = api.useUtils();
return (
<CardContent className="p-0">
<div className="flex flex-col gap-4">
@@ -36,12 +36,10 @@ export const SecurityAudit = ({ serverId }: Props) => {
<div className="flex items-center gap-2">
<LockKeyhole className="size-5" />
<CardTitle className="text-xl">
Setup Security Suggestions
Setup Security Sugestions
</CardTitle>
</div>
<CardDescription>
Check the security suggestions
</CardDescription>
<CardDescription>Check the security sugestions</CardDescription>
</div>
<Button
isLoading={isRefreshing}
@@ -122,36 +120,36 @@ export const SecurityAudit = ({ serverId }: Props) => {
<div className="grid gap-2.5">
<StatusRow
label="Enabled"
isEnabled={data?.ssh?.enabled}
isEnabled={data?.ssh.enabled}
description={
data?.ssh?.enabled
data?.ssh.enabled
? "Enabled"
: "Not Enabled (SSH should be enabled)"
}
/>
<StatusRow
label="Key Auth"
isEnabled={data?.ssh?.keyAuth}
isEnabled={data?.ssh.keyAuth}
description={
data?.ssh?.keyAuth
data?.ssh.keyAuth
? "Enabled (Recommended)"
: "Not Enabled (Key Authentication should be enabled)"
}
/>
<StatusRow
label="Password Auth"
isEnabled={data?.ssh?.passwordAuth === "no"}
isEnabled={data?.ssh.passwordAuth === "no"}
description={
data?.ssh?.passwordAuth === "no"
data?.ssh.passwordAuth === "no"
? "Disabled (Recommended)"
: "Enabled (Password Authentication should be disabled)"
}
/>
<StatusRow
label="Use PAM"
isEnabled={data?.ssh?.usePam === "no"}
isEnabled={data?.ssh.usePam === "no"}
description={
data?.ssh?.usePam === "no"
data?.ssh.usePam === "no"
? "Disabled (Recommended for key-based auth)"
: "Enabled (Should be disabled when using key-based auth)"
}
@@ -168,9 +166,9 @@ export const SecurityAudit = ({ serverId }: Props) => {
<div className="grid gap-2.5">
<StatusRow
label="Installed"
isEnabled={data?.fail2ban?.installed}
isEnabled={data?.fail2ban.installed}
description={
data?.fail2ban?.installed
data?.fail2ban.installed
? "Installed (Recommended)"
: "Not Installed (Fail2Ban should be installed for protection against brute force attacks)"
}
@@ -178,18 +176,18 @@ export const SecurityAudit = ({ serverId }: Props) => {
<StatusRow
label="Enabled"
isEnabled={data?.fail2ban?.enabled}
isEnabled={data?.fail2ban.enabled}
description={
data?.fail2ban?.enabled
data?.fail2ban.enabled
? "Enabled (Recommended)"
: "Not Enabled (Fail2Ban service should be enabled)"
}
/>
<StatusRow
label="Active"
isEnabled={data?.fail2ban?.active}
isEnabled={data?.fail2ban.active}
description={
data?.fail2ban?.active
data?.fail2ban.active
? "Active (Recommended)"
: "Not Active (Fail2Ban service should be running)"
}
@@ -197,9 +195,9 @@ export const SecurityAudit = ({ serverId }: Props) => {
<StatusRow
label="SSH Protection"
isEnabled={data?.fail2ban?.sshEnabled === "true"}
isEnabled={data?.fail2ban.sshEnabled === "true"}
description={
data?.fail2ban?.sshEnabled === "true"
data?.fail2ban.sshEnabled === "true"
? "Enabled (Recommended)"
: "Not Enabled (SSH protection should be enabled to prevent brute force attacks)"
}
@@ -207,11 +205,11 @@ export const SecurityAudit = ({ serverId }: Props) => {
<StatusRow
label="SSH Mode"
isEnabled={data?.fail2ban?.sshMode === "aggressive"}
isEnabled={data?.fail2ban.sshMode === "aggressive"}
description={
data?.fail2ban?.sshMode === "aggressive"
data?.fail2ban.sshMode === "aggressive"
? "Aggressive Mode (Recommended)"
: `Mode: ${data?.fail2ban?.sshMode || "Not Set"} (Aggressive mode recommended for better protection)`
: `Mode: ${data?.fail2ban.sshMode || "Not Set"} (Aggressive mode recommended for better protection)`
}
/>
</div>

View File

@@ -33,7 +33,6 @@ import { useTranslation } from "next-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions";
import { HandleServers } from "./handle-servers";
@@ -329,9 +328,6 @@ export const ShowServers = () => {
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>

View File

@@ -112,17 +112,15 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
toast.error("Error generating the SSH Key");
});
const downloadKey = (content: string, keyType: "private" | "public") => {
const downloadKey = (
content: string,
defaultFilename: string,
keyType: "private" | "public",
) => {
const keyName = form.watch("name");
const publicKey = form.watch("publicKey");
// Extract algorithm type from public key
const isEd25519 = publicKey.startsWith("ssh-ed25519");
const defaultName = isEd25519 ? "id_ed25519" : "id_rsa";
const filename = keyName
? `${keyName}${sshKeyId ? `_${sshKeyId}` : ""}_${keyType}_${defaultName}${keyType === "public" ? ".pub" : ""}`
: `${defaultName}${keyType === "public" ? ".pub" : ""}`;
? `${keyName}${sshKeyId ? `_${sshKeyId}` : ""}_${keyType}_${defaultFilename}`
: `${keyType}_${defaultFilename}`;
const blob = new Blob([content], { type: "text/plain" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
@@ -275,7 +273,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
variant="outline"
size="default"
onClick={() =>
downloadKey(form.watch("privateKey"), "private")
downloadKey(form.watch("privateKey"), "id_rsa", "private")
}
className="flex items-center gap-2"
>
@@ -289,7 +287,11 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
variant="outline"
size="default"
onClick={() =>
downloadKey(form.watch("publicKey"), "public")
downloadKey(
form.watch("publicKey"),
"id_rsa.pub",
"public",
)
}
className="flex items-center gap-2"
>

View File

@@ -9,7 +9,6 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -23,7 +22,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { GlobeIcon } from "lucide-react";
@@ -35,19 +33,11 @@ import { z } from "zod";
const addServerDomain = z
.object({
domain: z.string(),
domain: z.string().min(1, { message: "URL is required" }),
letsEncryptEmail: z.string(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]),
certificateType: z.enum(["letsencrypt", "none"]),
})
.superRefine((data, ctx) => {
if (data.https && !data.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
message: "Required",
});
}
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -71,18 +61,15 @@ export const WebDomain = () => {
domain: "",
certificateType: "none",
letsEncryptEmail: "",
https: false,
},
resolver: zodResolver(addServerDomain),
});
const https = form.watch("https");
useEffect(() => {
if (data) {
form.reset({
domain: data?.user?.host || "",
certificateType: data?.user?.certificateType,
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
https: data?.user?.https || false,
});
}
}, [form, form.reset, data]);
@@ -92,7 +79,6 @@ export const WebDomain = () => {
host: data.domain,
letsEncryptEmail: data.letsEncryptEmail,
certificateType: data.certificateType,
https: data.https,
})
.then(async () => {
await refetch();
@@ -169,68 +155,44 @@ export const WebDomain = () => {
/>
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm w-full col-span-2">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
name="certificateType"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
</FormItem>
);
}}
/>
{https && (
<FormField
control={form.control}
name="certificateType"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
)}
<div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit">
{t("settings.common.save")}

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -24,7 +23,6 @@ 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(
() =>
@@ -111,10 +109,7 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}){" "}
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.name} ({container.containerId}) {container.state}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>

View File

@@ -19,6 +19,13 @@ 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";
@@ -37,6 +44,7 @@ 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({
@@ -80,7 +88,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
}, [currentPorts, form]);
const handleAddPort = () => {
append({ targetPort: 0, publishedPort: 0 });
append({ targetPort: 0, publishedPort: 0, publishMode: "host" });
};
const onSubmit = async (data: TraefikPortsForm) => {
@@ -91,7 +99,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
});
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
setOpen(false);
} catch (_error) {}
} catch (_error) {
toast.error(t("settings.server.webServer.traefik.portsUpdateError"));
}
};
return (
@@ -144,7 +154,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_auto] gap-4 p-4 transparent">
<CardContent className="grid grid-cols-[1fr_1fr_1.5fr_auto] gap-4 p-4 transparent">
<FormField
control={form.control}
name={`ports.${index}.targetPort`}
@@ -159,15 +169,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<Input
type="number"
{...field}
onChange={(e) => {
const value = e.target.value;
field.onChange(
value === ""
? undefined
: Number(value),
);
}}
value={field.value || ""}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
className="w-full dark:bg-black"
placeholder="e.g. 8080"
/>
@@ -191,15 +195,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<Input
type="number"
{...field}
onChange={(e) => {
const value = e.target.value;
field.onChange(
value === ""
? undefined
: Number(value),
);
}}
value={field.value || ""}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
className="w-full dark:bg-black"
placeholder="e.g. 80"
/>
@@ -209,6 +207,39 @@ 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)}
@@ -232,23 +263,30 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<span className="text-sm">
<strong>
Each port mapping defines how external traffic reaches
your containers through Traefik.
your containers.
</strong>
<ul className="pt-2">
<li>
<strong>Target Port:</strong> The port inside your
container that the service is listening on.
<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>
</li>
<li>
<strong>Published Port:</strong> The port on your
host machine that will be mapped to the target port.
<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>
</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

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
@@ -22,7 +21,6 @@ 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";
export const DockerLogsId = dynamic(
() =>
@@ -38,20 +36,13 @@ interface Props {
appName: string;
children?: React.ReactNode;
serverId?: string;
type?: "standalone" | "swarm";
}
export const ShowModalLogs = ({
appName,
children,
serverId,
type = "swarm",
}: Props) => {
export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
const { data, isLoading } = api.docker.getContainersByAppLabel.useQuery(
{
appName,
serverId,
type,
},
{
enabled: !!appName,
@@ -92,10 +83,7 @@ export const ShowModalLogs = ({
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}){" "}
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
{container.name} ({container.containerId}) {container.state}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>

View File

@@ -1,119 +0,0 @@
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 { AlertTriangle, DatabaseIcon } 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

@@ -1,20 +0,0 @@
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
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="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
<div className="flex flex-row gap-4">
{nodes.map((node) => (
<NodeCard key={node.ID} node={node} serverId={serverId} />
))}

View File

@@ -238,41 +238,6 @@ export const BitbucketIcon = ({ className }: Props) => {
);
};
export const GiteaIcon = ({ className }: Props) => {
return (
<svg
className={className}
version="1.1"
id="main_outline"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="5.67 143.05 628.65 387.55"
enableBackground="new 0 0 640 640"
>
<g>
<path
id="teabag"
style={{ fill: "#FFFFFF" }}
d="M395.9,484.2l-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5,21.2-17.9,33.8-11.8c17.2,8.3,27.1,13,27.1,13l-0.1-109.2l16.7-0.1l0.1,117.1c0,0,57.4,24.2,83.1,40.1c3.7,2.3,10.2,6.8,12.9,14.4c2.1,6.1,2,13.1-1,19.3l-61,126.9C423.6,484.9,408.4,490.3,395.9,484.2z"
/>
<g>
<g>
<path
style={{ fill: "#609926" }}
d="M622.7,149.8c-4.1-4.1-9.6-4-9.6-4s-117.2,6.6-177.9,8c-13.3,0.3-26.5,0.6-39.6,0.7c0,39.1,0,78.2,0,117.2c-5.5-2.6-11.1-5.3-16.6-7.9c0-36.4-0.1-109.2-0.1-109.2c-29,0.4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-0.6-22.5-2.1-39,1.5c-8.7,1.8-33.5,7.4-53.8,26.9C-4.9,212.4,6.6,276.2,8,285.8c1.7,11.7,6.9,44.2,31.7,72.5c45.8,56.1,144.4,54.8,144.4,54.8s12.1,28.9,30.6,55.5c25,33.1,50.7,58.9,75.7,62c63,0,188.9-0.1,188.9-0.1s12,0.1,28.3-10.3c14-8.5,26.5-23.4,26.5-23.4s12.9-13.8,30.9-45.3c5.5-9.7,10.1-19.1,14.1-28c0,0,55.2-117.1,55.2-231.1C633.2,157.9,624.7,151.8,622.7,149.8z M125.6,353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6,321.8,60,295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5,38.5-30c13.8-3.7,31-3.1,31-3.1s7.1,59.4,15.7,94.2c7.2,29.2,24.8,77.7,24.8,77.7S142.5,359.9,125.6,353.9z M425.9,461.5c0,0-6.1,14.5-19.6,15.4c-5.8,0.4-10.3-1.2-10.3-1.2s-0.3-0.1-5.3-2.1l-112.9-55c0,0-10.9-5.7-12.8-15.6c-2.2-8.1,2.7-18.1,2.7-18.1L322,273c0,0,4.8-9.7,12.2-13c0.6-0.3,2.3-1,4.5-1.5c8.1-2.1,18,2.8,18,2.8l110.7,53.7c0,0,12.6,5.7,15.3,16.2c1.9,7.4-0.5,14-1.8,17.2C474.6,363.8,425.9,461.5,425.9,461.5z"
/>
<path
style={{ fill: "#609926" }}
d="M326.8,380.1c-8.2,0.1-15.4,5.8-17.3,13.8c-1.9,8,2,16.3,9.1,20c7.7,4,17.5,1.8,22.7-5.4c5.1-7.1,4.3-16.9-1.8-23.1l24-49.1c1.5,0.1,3.7,0.2,6.2-0.5c4.1-0.9,7.1-3.6,7.1-3.6c4.2,1.8,8.6,3.8,13.2,6.1c4.8,2.4,9.3,4.9,13.4,7.3c0.9,0.5,1.8,1.1,2.8,1.9c1.6,1.3,3.4,3.1,4.7,5.5c1.9,5.5-1.9,14.9-1.9,14.9c-2.3,7.6-18.4,40.6-18.4,40.6c-8.1-0.2-15.3,5-17.7,12.5c-2.6,8.1,1.1,17.3,8.9,21.3c7.8,4,17.4,1.7,22.5-5.3c5-6.8,4.6-16.3-1.1-22.6c1.9-3.7,3.7-7.4,5.6-11.3c5-10.4,13.5-30.4,13.5-30.4c0.9-1.7,5.7-10.3,2.7-21.3c-2.5-11.4-12.6-16.7-12.6-16.7c-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3c4.7-9.7,9.4-19.3,14.1-29c-4.1-2-8.1-4-12.2-6.1c-4.8,9.8-9.7,19.7-14.5,29.5c-6.7-0.1-12.9,3.5-16.1,9.4c-3.4,6.3-2.7,14.1,1.9,19.8C343.2,346.5,335,363.3,326.8,380.1z"
/>
</g>
</g>
</g>
</svg>
);
};
export const DockerIcon = ({ className }: Props) => {
return (
<svg

View File

@@ -496,6 +496,7 @@ 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 {
@@ -771,7 +772,6 @@ 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();
@@ -908,7 +908,7 @@ export default function Page({ children }: Props) {
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Settings</SidebarGroupLabel>
<SidebarMenu className="gap-1">
<SidebarMenu className="gap-2">
{filteredSettings.map((item) => {
const isSingle = item.isSingle !== false;
const isActive = isSingle
@@ -1028,16 +1028,6 @@ export default function Page({ children }: Props) {
<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 />
@@ -1068,7 +1058,7 @@ export default function Page({ children }: Props) {
</header>
)}
<div className="flex flex-col w-full p-4 pt-0">{children}</div>
<div className="flex flex-col w-full gap-4 p-4 pt-0">{children}</div>
</SidebarInset>
</SidebarProvider>
);

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