Merge branch 'canary' into fix-mount-update

This commit is contained in:
Mauricio Siu
2025-04-26 16:44:49 -06:00
201 changed files with 40191 additions and 3332 deletions

View File

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

22
.github/workflows/format.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
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. 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 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.
```bash ```bash
git clone https://github.com/dokploy/dokploy.git git clone https://github.com/dokploy/dokploy.git
@@ -61,9 +61,9 @@ pnpm install
cp apps/dokploy/.env.example apps/dokploy/.env cp apps/dokploy/.env.example apps/dokploy/.env
``` ```
## Development ## Requirements
Is required to have **Docker** installed on your machine. - [Docker](/GUIDES.md#docker)
### Setup ### Setup
@@ -87,6 +87,8 @@ pnpm run dokploy:dev
Go to http://localhost:3000 to see the development server 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 ## Build
```bash ```bash
@@ -145,11 +147,9 @@ curl -sSL https://railpack.com/install.sh | sh
```bash ```bash
# Install Buildpacks # 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 curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
``` ```
## Pull Request ## Pull Request
- The `main` branch is the source of truth and should always reflect the latest stable release. - The `main` branch is the source of truth and should always reflect the latest stable release.
@@ -167,7 +167,6 @@ Thank you for your contribution!
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 `https://github.com/Dokploy/templates` repository and read the README.md file.
### Recommendations ### Recommendations
- Use the same name of the folder as the id of the template. - Use the same name of the folder as the id of the template.

View File

@@ -29,7 +29,7 @@ WORKDIR /app
# Set production # Set production
ENV NODE_ENV=production ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl unzip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files # Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next COPY --from=build /prod/dokploy/.next ./.next
@@ -49,7 +49,7 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
# Install Nixpacks and tsx # Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash # | VERBOSE=1 VERSION=1.21.0 bash
ARG NIXPACKS_VERSION=1.29.1 ARG NIXPACKS_VERSION=1.35.0
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \ RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \ && chmod +x install.sh \
&& ./install.sh \ && ./install.sh \

49
GUIDES.md Normal file
View File

@@ -0,0 +1,49 @@
# 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

@@ -1006,7 +1006,7 @@ services:
volumes: volumes:
db-config-testhash: db-config-testhash:
`) as ComposeSpecification; `);
test("Expect to change the suffix in all the possible places (4 Try)", () => { test("Expect to change the suffix in all the possible places (4 Try)", () => {
const composeData = load(composeFileComplex) as ComposeSpecification; const composeData = load(composeFileComplex) as ComposeSpecification;
@@ -1115,3 +1115,60 @@ test("Expect to change the suffix in all the possible places (5 Try)", () => {
expect(updatedComposeData).toEqual(expectedDockerComposeExample1); 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,6 +27,11 @@ if (typeof window === "undefined") {
const baseApp: ApplicationNested = { const baseApp: ApplicationNested = {
applicationId: "", applicationId: "",
herokuVersion: "", herokuVersion: "",
giteaBranch: "",
giteaBuildPath: "",
giteaId: "",
giteaOwner: "",
giteaRepository: "",
cleanCache: false, cleanCache: false,
watchPaths: [], watchPaths: [],
applicationStatus: "done", applicationStatus: "done",

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import type { Schema } from "@dokploy/server/templates";
import type { CompleteTemplate } from "@dokploy/server/templates/processors"; import type { CompleteTemplate } from "@dokploy/server/templates/processors";
import { processTemplate } from "@dokploy/server/templates/processors"; import { processTemplate } from "@dokploy/server/templates/processors";
import type { Schema } from "@dokploy/server/templates"; import { describe, expect, it } from "vitest";
describe("processTemplate", () => { describe("processTemplate", () => {
// Mock schema for testing // Mock schema for testing
@@ -51,6 +51,35 @@ describe("processTemplate", () => {
expect(result.domains).toHaveLength(0); expect(result.domains).toHaveLength(0);
expect(result.mounts).toHaveLength(0); expect(result.mounts).toHaveLength(0);
}); });
it("should allow creation of real jwt secret", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ",
anon_payload: JSON.stringify({
role: "tester",
iss: "dockploy",
iat: "${timestamps:2025-01-01T00:00:00Z}",
exp: "${timestamps:2030-01-01T00:00:00Z}",
}),
anon_key: "${jwt:jwt_secret:anon_payload}",
},
config: {
domains: [],
env: {
ANON_KEY: "${anon_key}",
},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(1);
expect(result.envs).toContain(
"ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY",
);
expect(result.mounts).toHaveLength(0);
expect(result.domains).toHaveLength(0);
});
}); });
describe("domains processing", () => { describe("domains processing", () => {
@@ -233,6 +262,49 @@ describe("processTemplate", () => {
expect(base64Value.length).toBeGreaterThanOrEqual(42); expect(base64Value.length).toBeGreaterThanOrEqual(42);
expect(base64Value.length).toBeLessThanOrEqual(44); 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", () => { describe("mounts processing", () => {

View File

@@ -0,0 +1,232 @@
import type { Schema } from "@dokploy/server/templates";
import { processValue } from "@dokploy/server/templates/processors";
import { describe, expect, it } from "vitest";
describe("helpers functions", () => {
// Mock schema for testing
const mockSchema: Schema = {
projectName: "test",
serverIp: "127.0.0.1",
};
// some helpers to test jwt
type JWTParts = [string, string, string];
const jwtMatchExp = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
const jwtBase64Decode = (str: string) => {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const decoded = Buffer.from(base64 + padding, "base64").toString("utf-8");
return JSON.parse(decoded);
};
const jwtCheckHeader = (jwtHeader: string) => {
const decodedHeader = jwtBase64Decode(jwtHeader);
expect(decodedHeader).toHaveProperty("alg");
expect(decodedHeader).toHaveProperty("typ");
expect(decodedHeader.alg).toEqual("HS256");
expect(decodedHeader.typ).toEqual("JWT");
};
describe("${domain}", () => {
it("should generate a random domain", () => {
const domain = processValue("${domain}", {}, mockSchema);
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
expect(
domain.endsWith(
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
),
).toBeTruthy();
});
});
describe("${base64}", () => {
it("should generate a base64 string", () => {
const base64 = processValue("${base64}", {}, mockSchema);
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
});
it.each([
[4, 8],
[8, 12],
[16, 24],
[32, 44],
[64, 88],
[128, 172],
])(
"should generate a base64 string from parameter %d bytes length",
(length, finalLength) => {
const base64 = processValue(`\${base64:${length}}`, {}, mockSchema);
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
expect(base64.length).toBe(finalLength);
},
);
});
describe("${password}", () => {
it("should generate a password string", () => {
const password = processValue("${password}", {}, mockSchema);
expect(password).toMatch(/^[A-Za-z0-9]+$/);
});
it.each([6, 8, 12, 16, 32])(
"should generate a password string respecting parameter %d length",
(length) => {
const password = processValue(`\${password:${length}}`, {}, mockSchema);
expect(password).toMatch(/^[A-Za-z0-9]+$/);
expect(password.length).toBe(length);
},
);
});
describe("${hash}", () => {
it("should generate a hash string", () => {
const hash = processValue("${hash}", {}, mockSchema);
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
});
it.each([6, 8, 12, 16, 32])(
"should generate a hash string respecting parameter %d length",
(length) => {
const hash = processValue(`\${hash:${length}}`, {}, mockSchema);
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
expect(hash.length).toBe(length);
},
);
});
describe("${uuid}", () => {
it("should generate a UUID string", () => {
const uuid = processValue("${uuid}", {}, mockSchema);
expect(uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
});
});
describe("${timestamp}", () => {
it("should generate a timestamp string in milliseconds", () => {
const timestamp = processValue("${timestamp}", {}, mockSchema);
const nowLength = Math.floor(Date.now()).toString().length;
expect(timestamp).toMatch(/^\d+$/);
expect(timestamp.length).toBe(nowLength);
});
});
describe("${timestampms}", () => {
it("should generate a timestamp string in milliseconds", () => {
const timestamp = processValue("${timestampms}", {}, mockSchema);
const nowLength = Date.now().toString().length;
expect(timestamp).toMatch(/^\d+$/);
expect(timestamp.length).toBe(nowLength);
});
it("should generate a timestamp string in milliseconds from parameter", () => {
const timestamp = processValue(
"${timestampms:2025-01-01}",
{},
mockSchema,
);
expect(timestamp).toEqual("1735689600000");
});
});
describe("${timestamps}", () => {
it("should generate a timestamp string in seconds", () => {
const timestamps = processValue("${timestamps}", {}, mockSchema);
const nowLength = Math.floor(Date.now() / 1000).toString().length;
expect(timestamps).toMatch(/^\d+$/);
expect(timestamps.length).toBe(nowLength);
});
it("should generate a timestamp string in seconds from parameter", () => {
const timestamps = processValue(
"${timestamps:2025-01-01}",
{},
mockSchema,
);
expect(timestamps).toEqual("1735689600");
});
});
describe("${randomPort}", () => {
it("should generate a random port string", () => {
const randomPort = processValue("${randomPort}", {}, mockSchema);
expect(randomPort).toMatch(/^\d+$/);
expect(Number(randomPort)).toBeLessThan(65536);
});
});
describe("${username}", () => {
it("should generate a username string", () => {
const username = processValue("${username}", {}, mockSchema);
expect(username).toMatch(/^[a-zA-Z0-9._-]{3,}$/);
});
});
describe("${email}", () => {
it("should generate an email string", () => {
const email = processValue("${email}", {}, mockSchema);
expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/);
});
});
describe("${jwt}", () => {
it("should generate a JWT string", () => {
const jwt = processValue("${jwt}", {}, mockSchema);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
const decodedPayload = jwtBase64Decode(parts[1]);
jwtCheckHeader(parts[0]);
expect(decodedPayload).toHaveProperty("iat");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.iss).toEqual("dokploy");
});
it.each([6, 8, 12, 16, 32])(
"should generate a random hex string from parameter %d byte length",
(length) => {
const jwt = processValue(`\${jwt:${length}}`, {}, mockSchema);
expect(jwt).toMatch(/^[A-Za-z0-9-_.]+$/);
expect(jwt.length).toBeGreaterThanOrEqual(length); // bytes translated to hex can take up to 2x the length
expect(jwt.length).toBeLessThanOrEqual(length * 2);
},
);
});
describe("${jwt:secret}", () => {
it("should generate a JWT string respecting parameter secret from variable", () => {
const jwt = processValue(
"${jwt:secret}",
{ secret: "mysecret" },
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
const decodedPayload = jwtBase64Decode(parts[1]);
jwtCheckHeader(parts[0]);
expect(decodedPayload).toHaveProperty("iat");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.iss).toEqual("dokploy");
});
});
describe("${jwt:secret:payload}", () => {
it("should generate a JWT string respecting parameters secret and payload from variables", () => {
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
const expiry = iat + 3600;
const jwt = processValue(
"${jwt:secret:payload}",
{
secret: "mysecret",
payload: `{"iss": "test-issuer", "iat": ${iat}, "exp": ${expiry}, "customprop": "customvalue"}`,
},
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
jwtCheckHeader(parts[0]);
const decodedPayload = jwtBase64Decode(parts[1]);
expect(decodedPayload).toHaveProperty("iat");
expect(decodedPayload.iat).toEqual(iat);
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload.iss).toEqual("test-issuer");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.exp).toEqual(expiry);
expect(decodedPayload).toHaveProperty("customprop");
expect(decodedPayload.customprop).toEqual("customvalue");
expect(jwt).toEqual(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
);
});
});
});

View File

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

View File

@@ -7,6 +7,11 @@ import { expect, test } from "vitest";
const baseApp: ApplicationNested = { const baseApp: ApplicationNested = {
applicationId: "", applicationId: "",
herokuVersion: "", herokuVersion: "",
giteaRepository: "",
giteaOwner: "",
giteaBranch: "",
giteaBuildPath: "",
giteaId: "",
cleanCache: false, cleanCache: false,
applicationStatus: "done", applicationStatus: "done",
appName: "", appName: "",

View File

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

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor"; import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -32,7 +33,6 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
const ImportSchema = z.object({ const ImportSchema = z.object({
base64: z.string(), base64: z.string(),

View File

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

View File

@@ -41,6 +41,7 @@ import { toast } from "sonner";
import { domain } from "@/server/db/validations/domain"; import { domain } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Dices } from "lucide-react"; import { Dices } from "lucide-react";
import Link from "next/link";
import type z from "zod"; import type z from "zod";
type Domain = z.infer<typeof domain>; type Domain = z.infer<typeof domain>;
@@ -83,6 +84,13 @@ export const AddDomain = ({
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation(); api.domain.generateDomain.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: application?.serverId || "",
});
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
const form = useForm<Domain>({ const form = useForm<Domain>({
resolver: zodResolver(domain), resolver: zodResolver(domain),
defaultValues: { defaultValues: {
@@ -186,6 +194,21 @@ export const AddDomain = ({
name="host" name="host"
render={({ field }) => ( render={({ field }) => (
<FormItem> <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> <FormLabel>Host</FormLabel>
<div className="flex gap-2"> <div className="flex gap-2">
<FormControl> <FormControl>

View File

@@ -4,10 +4,10 @@ import { Form } from "@/components/ui/form";
import { Secrets } from "@/components/ui/secrets"; import { Secrets } from "@/components/ui/secrets";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { useEffect } from "react";
const addEnvironmentSchema = z.object({ const addEnvironmentSchema = z.object({
env: z.string(), env: z.string(),

View File

@@ -1,4 +1,6 @@
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -39,13 +41,11 @@ import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const BitbucketProviderSchema = z.object({ const BitbucketProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"), buildPath: z.string().min(1, "Path is required").default("/"),

View File

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

View File

@@ -26,15 +26,15 @@ import {
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import { useRouter } from "next/router";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { GitIcon } from "@/components/icons/data-tools-icons";
const GitProviderSchema = z.object({ const GitProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"), buildPath: z.string().min(1, "Path is required").default("/"),

View File

@@ -0,0 +1,515 @@
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,3 +1,5 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -34,17 +36,15 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import Link from "next/link";
import { GithubIcon } from "@/components/icons/data-tools-icons";
const GithubProviderSchema = z.object({ const GithubProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"), buildPath: z.string().min(1, "Path is required").default("/"),

View File

@@ -1,4 +1,6 @@
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -35,17 +37,15 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import Link from "next/link";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
const GitlabProviderSchema = z.object({ const GitlabProviderSchema = z.object({
buildPath: z.string().min(1, "Path is required").default("/"), buildPath: z.string().min(1, "Path is required").default("/"),

View File

@@ -1,10 +1,12 @@
import { SaveDockerProvider } from "@/components/dashboard/application/general/generic/save-docker-provider"; import { SaveDockerProvider } from "@/components/dashboard/application/general/generic/save-docker-provider";
import { SaveGitProvider } from "@/components/dashboard/application/general/generic/save-git-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 { SaveGithubProvider } from "@/components/dashboard/application/general/generic/save-github-provider";
import { import {
BitbucketIcon, BitbucketIcon,
DockerIcon, DockerIcon,
GitIcon, GitIcon,
GiteaIcon,
GithubIcon, GithubIcon,
GitlabIcon, GitlabIcon,
} from "@/components/icons/data-tools-icons"; } from "@/components/icons/data-tools-icons";
@@ -18,7 +20,14 @@ import { SaveBitbucketProvider } from "./save-bitbucket-provider";
import { SaveDragNDrop } from "./save-drag-n-drop"; import { SaveDragNDrop } from "./save-drag-n-drop";
import { SaveGitlabProvider } from "./save-gitlab-provider"; import { SaveGitlabProvider } from "./save-gitlab-provider";
type TabState = "github" | "docker" | "git" | "drop" | "gitlab" | "bitbucket"; type TabState =
| "github"
| "docker"
| "git"
| "drop"
| "gitlab"
| "bitbucket"
| "gitea";
interface Props { interface Props {
applicationId: string; applicationId: string;
@@ -29,6 +38,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } = const { data: bitbucketProviders } =
api.bitbucket.bitbucketProviders.useQuery(); api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: application } = api.application.one.useQuery({ applicationId }); const { data: application } = api.application.one.useQuery({ applicationId });
const [tab, setSab] = useState<TabState>(application?.sourceType || "github"); const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
@@ -78,6 +88,13 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
<BitbucketIcon className="size-4 text-current fill-current" /> <BitbucketIcon className="size-4 text-current fill-current" />
Bitbucket Bitbucket
</TabsTrigger> </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 <TabsTrigger
value="docker" 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" className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
@@ -162,6 +179,26 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
</div> </div>
)} )}
</TabsContent> </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"> <TabsContent value="docker" className="w-full p-2">
<SaveDockerProvider applicationId={applicationId} /> <SaveDockerProvider applicationId={applicationId} />
</TabsContent> </TabsContent>

View File

@@ -298,7 +298,11 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
}) })
.then(() => { .then(() => {
refetch(); refetch();
toast.success("Preview deployments enabled"); toast.success(
checked
? "Preview deployments enabled"
: "Preview deployments disabled",
);
}) })
.catch((error) => { .catch((error) => {
toast.error(error.message); toast.error(error.message);

View File

@@ -41,6 +41,7 @@ import {
import { domainCompose } from "@/server/db/validations/domain"; import { domainCompose } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react"; import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import type z from "zod"; import type z from "zod";
type Domain = z.infer<typeof domainCompose>; type Domain = z.infer<typeof domainCompose>;
@@ -102,6 +103,11 @@ export const AddDomainCompose = ({
? api.domain.update.useMutation() ? api.domain.update.useMutation()
: api.domain.create.useMutation(); : api.domain.create.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: compose?.serverId || "",
});
const form = useForm<Domain>({ const form = useForm<Domain>({
resolver: zodResolver(domainCompose), resolver: zodResolver(domainCompose),
defaultValues: { defaultValues: {
@@ -313,6 +319,21 @@ export const AddDomainCompose = ({
name="host" name="host"
render={({ field }) => ( render={({ field }) => (
<FormItem> <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> <FormLabel>Host</FormLabel>
<div className="flex gap-2"> <div className="flex gap-2">
<FormControl> <FormControl>

View File

@@ -79,6 +79,22 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
toast.error("Error updating the Compose config"); toast.error("Error updating the Compose config");
}); });
}; };
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's' && !isLoading) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [form, onSubmit, isLoading]);
return ( return (
<> <>
<div className="w-full flex flex-col gap-4 "> <div className="w-full flex flex-col gap-4 ">

View File

@@ -1,4 +1,6 @@
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -39,13 +41,11 @@ import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const BitbucketProviderSchema = z.object({ const BitbucketProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),

View File

@@ -1,3 +1,4 @@
import { GitIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -27,13 +28,12 @@ import {
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { GitIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GitProviderSchema = z.object({ const GitProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),

View File

@@ -0,0 +1,483 @@
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,3 +1,4 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -39,12 +40,11 @@ import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GithubProviderSchema = z.object({ const GithubProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),

View File

@@ -1,4 +1,6 @@
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -39,13 +41,11 @@ import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { Badge } from "@/components/ui/badge";
import { GitlabIcon } from "@/components/icons/data-tools-icons";
import Link from "next/link";
const GitlabProviderSchema = z.object({ const GitlabProviderSchema = z.object({
composePath: z.string().min(1), composePath: z.string().min(1),

View File

@@ -1,6 +1,7 @@
import { import {
BitbucketIcon, BitbucketIcon,
GitIcon, GitIcon,
GiteaIcon,
GithubIcon, GithubIcon,
GitlabIcon, GitlabIcon,
} from "@/components/icons/data-tools-icons"; } from "@/components/icons/data-tools-icons";
@@ -14,10 +15,11 @@ import { ComposeFileEditor } from "../compose-file-editor";
import { ShowConvertedCompose } from "../show-converted-compose"; import { ShowConvertedCompose } from "../show-converted-compose";
import { SaveBitbucketProviderCompose } from "./save-bitbucket-provider-compose"; import { SaveBitbucketProviderCompose } from "./save-bitbucket-provider-compose";
import { SaveGitProviderCompose } from "./save-git-provider-compose"; import { SaveGitProviderCompose } from "./save-git-provider-compose";
import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose";
import { SaveGithubProviderCompose } from "./save-github-provider-compose"; import { SaveGithubProviderCompose } from "./save-github-provider-compose";
import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose"; import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose";
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket"; type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea";
interface Props { interface Props {
composeId: string; composeId: string;
} }
@@ -27,9 +29,11 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } = const { data: bitbucketProviders } =
api.bitbucket.bitbucketProviders.useQuery(); api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: compose } = api.compose.one.useQuery({ composeId }); const { data: compose } = api.compose.one.useQuery({ composeId });
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github"); const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
return ( return (
<Card className="group relative w-full bg-transparent"> <Card className="group relative w-full bg-transparent">
<CardHeader> <CardHeader>
@@ -55,20 +59,20 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4"> <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"> <TabsList className="md:grid md:w-fit md:grid-cols-6 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
<TabsTrigger <TabsTrigger
value="github" 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" 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" /> <GithubIcon className="size-4 text-current fill-current" />
Github GitHub
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="gitlab" 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" 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" /> <GitlabIcon className="size-4 text-current fill-current" />
Gitlab GitLab
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="bitbucket" value="bitbucket"
@@ -77,7 +81,12 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
<BitbucketIcon className="size-4 text-current fill-current" /> <BitbucketIcon className="size-4 text-current fill-current" />
Bitbucket Bitbucket
</TabsTrigger> </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 <TabsTrigger
value="git" 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" className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
@@ -94,6 +103,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<TabsContent value="github" className="w-full p-2"> <TabsContent value="github" className="w-full p-2">
{githubProviders && githubProviders?.length > 0 ? ( {githubProviders && githubProviders?.length > 0 ? (
<SaveGithubProviderCompose composeId={composeId} /> <SaveGithubProviderCompose composeId={composeId} />
@@ -154,6 +164,26 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
</div> </div>
)} )}
</TabsContent> </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"> <TabsContent value="git" className="w-full p-2">
<SaveGitProviderCompose composeId={composeId} /> <SaveGitProviderCompose composeId={composeId} />
</TabsContent> </TabsContent>

View File

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

View File

@@ -62,6 +62,11 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {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"> <div className="flex flex-row gap-2 justify-end">
<Button <Button
variant="secondary" variant="secondary"

View File

@@ -61,7 +61,7 @@ type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
interface Props { interface Props {
databaseId: string; databaseId: string;
databaseType: "postgres" | "mariadb" | "mysql" | "mongo"; databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
refetch: () => void; refetch: () => void;
} }
@@ -85,7 +85,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
useEffect(() => { useEffect(() => {
form.reset({ form.reset({
database: "", database: databaseType === "web-server" ? "dokploy" : "",
destinationId: "", destinationId: "",
enabled: true, enabled: true,
prefix: "/", prefix: "/",
@@ -112,6 +112,10 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
? { ? {
mongoId: databaseId, mongoId: databaseId,
} }
: databaseType === "web-server"
? {
userId: databaseId,
}
: undefined; : undefined;
await createBackup({ await createBackup({
@@ -236,7 +240,11 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
<FormItem> <FormItem>
<FormLabel>Database</FormLabel> <FormLabel>Database</FormLabel>
<FormControl> <FormControl>
<Input placeholder={"dokploy"} {...field} /> <Input
disabled={databaseType === "web-server"}
placeholder={"dokploy"}
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -286,10 +294,15 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
<FormItem> <FormItem>
<FormLabel>Keep the latest</FormLabel> <FormLabel>Keep the latest</FormLabel>
<FormControl> <FormControl>
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} /> <Input
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Optional. If provided, only keeps the latest N backups in the cloud. Optional. If provided, only keeps the latest N backups
in the cloud.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -1,3 +1,5 @@
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Command, Command,
@@ -23,6 +25,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
@@ -32,22 +35,20 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; 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 { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import type { ServiceType } from "../../application/advanced/show-resources"; import type { ServiceType } from "../../application/advanced/show-resources";
import { debounce } from "lodash";
import { Input } from "@/components/ui/input";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DrawerLogs } from "@/components/shared/drawer-logs";
import { Badge } from "@/components/ui/badge";
import copy from "copy-to-clipboard";
import { toast } from "sonner";
interface Props { interface Props {
databaseId: string; databaseId: string;
databaseType: Exclude<ServiceType, "application" | "redis">; databaseType: Exclude<ServiceType, "application" | "redis"> | "web-server";
serverId?: string | null;
} }
const RestoreBackupSchema = z.object({ const RestoreBackupSchema = z.object({
@@ -76,9 +77,14 @@ const RestoreBackupSchema = z.object({
type RestoreBackup = z.infer<typeof RestoreBackupSchema>; type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
export const RestoreBackup = ({ databaseId, databaseType }: Props) => { export const RestoreBackup = ({
databaseId,
databaseType,
serverId,
}: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const { data: destinations = [] } = api.destination.all.useQuery(); const { data: destinations = [] } = api.destination.all.useQuery();
@@ -86,7 +92,7 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
defaultValues: { defaultValues: {
destinationId: "", destinationId: "",
backupFile: "", backupFile: "",
databaseName: "", databaseName: databaseType === "web-server" ? "dokploy" : "",
}, },
resolver: zodResolver(RestoreBackupSchema), resolver: zodResolver(RestoreBackupSchema),
}); });
@@ -94,13 +100,19 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
const destionationId = form.watch("destinationId"); const destionationId = form.watch("destinationId");
const debouncedSetSearch = debounce((value: string) => { const debouncedSetSearch = debounce((value: string) => {
setDebouncedSearchTerm(value);
}, 150);
const handleSearchChange = (value: string) => {
setSearch(value); setSearch(value);
}, 300); debouncedSetSearch(value);
};
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
{ {
destinationId: destionationId, destinationId: destionationId,
search, search: debouncedSearchTerm,
serverId: serverId ?? "",
}, },
{ {
enabled: isOpen && !!destionationId, enabled: isOpen && !!destionationId,
@@ -278,7 +290,8 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search backup files..." placeholder="Search backup files..."
onValueChange={debouncedSetSearch} value={search}
onValueChange={handleSearchChange}
className="h-9" className="h-9"
/> />
{isLoading ? ( {isLoading ? (
@@ -302,9 +315,13 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
key={file} key={file}
onSelect={() => { onSelect={() => {
form.setValue("backupFile", file); form.setValue("backupFile", file);
setSearch(file);
setDebouncedSearchTerm(file);
}} }}
> >
{file} <div className="flex w-full justify-between">
<span>{file}</span>
</div>
<CheckIcon <CheckIcon
className={cn( className={cn(
"ml-auto h-4 w-4", "ml-auto h-4 w-4",
@@ -332,7 +349,11 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
<FormItem className=""> <FormItem className="">
<FormLabel>Database Name</FormLabel> <FormLabel>Database Name</FormLabel>
<FormControl> <FormControl>
<Input {...field} placeholder="Enter database name" /> <Input
disabled={databaseType === "web-server"}
{...field}
placeholder="Enter database name"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -14,18 +14,18 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { DatabaseBackup, Play, Trash2 } from "lucide-react"; import { Database, DatabaseBackup, Play, Trash2 } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { ServiceType } from "../../application/advanced/show-resources"; import type { ServiceType } from "../../application/advanced/show-resources";
import { AddBackup } from "./add-backup"; import { AddBackup } from "./add-backup";
import { UpdateBackup } from "./update-backup";
import { RestoreBackup } from "./restore-backup"; import { RestoreBackup } from "./restore-backup";
import { useState } from "react"; import { UpdateBackup } from "./update-backup";
interface Props { interface Props {
id: string; id: string;
type: Exclude<ServiceType, "application" | "redis">; type: Exclude<ServiceType, "application" | "redis"> | "web-server";
} }
export const ShowBackups = ({ id, type }: Props) => { export const ShowBackups = ({ id, type }: Props) => {
const [activeManualBackup, setActiveManualBackup] = useState< const [activeManualBackup, setActiveManualBackup] = useState<
@@ -38,6 +38,7 @@ export const ShowBackups = ({ id, type }: Props) => {
mariadb: () => mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: 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 } = api.destination.all.useQuery();
const { data: postgres, refetch } = queryMap[type] const { data: postgres, refetch } = queryMap[type]
@@ -49,6 +50,7 @@ export const ShowBackups = ({ id, type }: Props) => {
mysql: () => api.backup.manualBackupMySql.useMutation(), mysql: () => api.backup.manualBackupMySql.useMutation(),
mariadb: () => api.backup.manualBackupMariadb.useMutation(), mariadb: () => api.backup.manualBackupMariadb.useMutation(),
mongo: () => api.backup.manualBackupMongo.useMutation(), mongo: () => api.backup.manualBackupMongo.useMutation(),
"web-server": () => api.backup.manualBackupWebServer.useMutation(),
}; };
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[ const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
@@ -64,7 +66,10 @@ export const ShowBackups = ({ id, type }: Props) => {
<Card className="bg-background"> <Card className="bg-background">
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap"> <CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<CardTitle className="text-xl">Backups</CardTitle> <CardTitle className="text-xl flex flex-row gap-2">
<Database className="size-6 text-muted-foreground" />
Backups
</CardTitle>
<CardDescription> <CardDescription>
Add backups to your database to save the data to a different Add backups to your database to save the data to a different
provider. provider.
@@ -73,8 +78,18 @@ export const ShowBackups = ({ id, type }: Props) => {
{postgres && postgres?.backups?.length > 0 && ( {postgres && postgres?.backups?.length > 0 && (
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto"> <div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
<AddBackup databaseId={id} databaseType={type} refetch={refetch} /> {type !== "web-server" && (
<RestoreBackup databaseId={id} databaseType={type} /> <AddBackup
databaseId={id}
databaseType={type}
refetch={refetch}
/>
)}
<RestoreBackup
databaseId={id}
databaseType={type}
serverId={"serverId" in postgres ? postgres.serverId : undefined}
/>
</div> </div>
)} )}
</CardHeader> </CardHeader>
@@ -108,7 +123,13 @@ export const ShowBackups = ({ id, type }: Props) => {
databaseType={type} databaseType={type}
refetch={refetch} refetch={refetch}
/> />
<RestoreBackup databaseId={id} databaseType={type} /> <RestoreBackup
databaseId={id}
databaseType={type}
serverId={
"serverId" in postgres ? postgres.serverId : undefined
}
/>
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -92,7 +92,9 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
enabled: backup.enabled || false, enabled: backup.enabled || false,
prefix: backup.prefix, prefix: backup.prefix,
schedule: backup.schedule, schedule: backup.schedule,
keepLatestCount: backup.keepLatestCount ? Number(backup.keepLatestCount) : undefined, keepLatestCount: backup.keepLatestCount
? Number(backup.keepLatestCount)
: undefined,
}); });
} }
}, [form, form.reset, backup]); }, [form, form.reset, backup]);
@@ -274,10 +276,15 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
<FormItem> <FormItem>
<FormLabel>Keep the latest</FormLabel> <FormLabel>Keep the latest</FormLabel>
<FormControl> <FormControl>
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} /> <Input
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Optional. If provided, only keeps the latest N backups in the cloud. Optional. If provided, only keeps the latest N backups
in the cloud.
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -33,7 +33,11 @@ const DockerProviderSchema = z.object({
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
} }
return null; return null;
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()), }, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
}); });
type DockerProvider = z.infer<typeof DockerProviderSchema>; type DockerProvider = z.infer<typeof DockerProviderSchema>;

View File

@@ -33,7 +33,11 @@ const DockerProviderSchema = z.object({
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
} }
return null; return null;
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()), }, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
}); });
type DockerProvider = z.infer<typeof DockerProviderSchema>; type DockerProvider = z.infer<typeof DockerProviderSchema>;

View File

@@ -24,7 +24,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
{ {
mongoId, mongoId,
}, },
{ enabled: !!mongoId } { enabled: !!mongoId },
); );
const { mutateAsync: reload, isLoading: isReloading } = const { mutateAsync: reload, isLoading: isReloading } =
@@ -61,7 +61,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
console.error("Deployment logs error:", error); console.error("Deployment logs error:", error);
setIsDeploying(false); setIsDeploying(false);
}, },
} },
); );
return ( return (
<> <>
@@ -82,16 +82,17 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
refetch(); refetch();
}} }}
> >
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="default" variant="default"
isLoading={data?.applicationStatus === "running"} isLoading={data?.applicationStatus === "running"}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" 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" /> <Rocket className="size-4 mr-1" />
Deploy Deploy
</Button> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
@@ -99,6 +100,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button>
</DialogAction> </DialogAction>
<DialogAction <DialogAction
title="Reload Mongo" title="Reload Mongo"
@@ -118,16 +120,17 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
}); });
}} }}
> >
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="secondary" variant="secondary"
isLoading={isReloading} isLoading={isReloading}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" 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" /> <RefreshCcw className="size-4 mr-1" />
Reload Reload
</Button> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
@@ -135,6 +138,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button>
</DialogAction> </DialogAction>
{data?.applicationStatus === "idle" ? ( {data?.applicationStatus === "idle" ? (
<DialogAction <DialogAction
@@ -154,16 +158,17 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
}); });
}} }}
> >
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="secondary" variant="secondary"
isLoading={isStarting} isLoading={isStarting}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" 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" /> <CheckCircle2 className="size-4 mr-1" />
Start Start
</Button> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
@@ -174,6 +179,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button>
</DialogAction> </DialogAction>
) : ( ) : (
<DialogAction <DialogAction
@@ -192,16 +198,17 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
}); });
}} }}
> >
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="destructive" variant="destructive"
isLoading={isStopping} isLoading={isStopping}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" 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" /> <Ban className="size-4 mr-1" />
Stop Stop
</Button> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
@@ -209,6 +216,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button>
</DialogAction> </DialogAction>
)} )}
</TooltipProvider> </TooltipProvider>
@@ -216,15 +224,16 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
appName={data?.appName || ""} appName={data?.appName || ""}
serverId={data?.serverId || ""} serverId={data?.serverId || ""}
> >
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<Terminal className="size-4" /> <Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Terminal className="size-4 mr-1" />
Open Terminal Open Terminal
</Button> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipContent sideOffset={5} className="z-[60]">
@@ -232,6 +241,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipContent> </TooltipContent>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
</Tooltip> </Tooltip>
</Button>
</DockerTerminalModal> </DockerTerminalModal>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -33,7 +33,11 @@ const DockerProviderSchema = z.object({
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
} }
return null; return null;
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()), }, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
}); });
type DockerProvider = z.infer<typeof DockerProviderSchema>; type DockerProvider = z.infer<typeof DockerProviderSchema>;

View File

@@ -33,7 +33,11 @@ const DockerProviderSchema = z.object({
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
} }
return null; return null;
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()), }, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
}); });
type DockerProvider = z.infer<typeof DockerProviderSchema>; type DockerProvider = z.infer<typeof DockerProviderSchema>;

View File

@@ -51,7 +51,7 @@ export const UpdatePostgres = ({ postgresId }: Props) => {
}, },
{ {
enabled: !!postgresId, enabled: !!postgresId,
} },
); );
const form = useForm<UpdatePostgres>({ const form = useForm<UpdatePostgres>({
defaultValues: { defaultValues: {

View File

@@ -67,7 +67,7 @@ import {
SearchIcon, SearchIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState, useEffect } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url"; const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
@@ -307,7 +307,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
> >
{templates?.map((template) => ( {templates?.map((template) => (
<div <div
key={template.id} key={template?.id}
className={cn( className={cn(
"flex flex-col border rounded-lg overflow-hidden relative", "flex flex-col border rounded-lg overflow-hidden relative",
viewMode === "icon" && "h-[200px]", viewMode === "icon" && "h-[200px]",
@@ -315,7 +315,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
)} )}
> >
<Badge className="absolute top-2 right-2" variant="blue"> <Badge className="absolute top-2 right-2" variant="blue">
{template.version} {template?.version}
</Badge> </Badge>
<div <div
className={cn( className={cn(
@@ -324,21 +324,21 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
)} )}
> >
<img <img
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template.id}/${template.logo}`} src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
className={cn( className={cn(
"object-contain", "object-contain",
viewMode === "detailed" ? "size-24" : "size-16", viewMode === "detailed" ? "size-24" : "size-16",
)} )}
alt={template.name} alt={template?.name}
/> />
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<span className="text-sm font-medium line-clamp-1"> <span className="text-sm font-medium line-clamp-1">
{template.name} {template?.name}
</span> </span>
{viewMode === "detailed" && {viewMode === "detailed" &&
template.tags.length > 0 && ( template?.tags?.length > 0 && (
<div className="flex flex-wrap justify-center gap-1.5"> <div className="flex flex-wrap justify-center gap-1.5">
{template.tags.map((tag) => ( {template?.tags?.map((tag) => (
<Badge <Badge
key={tag} key={tag}
variant="green" variant="green"
@@ -356,7 +356,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
{viewMode === "detailed" && ( {viewMode === "detailed" && (
<ScrollArea className="flex-1 p-6"> <ScrollArea className="flex-1 p-6">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{template.description} {template?.description}
</div> </div>
</ScrollArea> </ScrollArea>
)} )}
@@ -372,25 +372,27 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
> >
{viewMode === "detailed" && ( {viewMode === "detailed" && (
<div className="flex gap-2"> <div className="flex gap-2">
{template?.links?.github && (
<Link <Link
href={template.links.github} href={template?.links?.github}
target="_blank" target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
<GithubIcon className="size-5" /> <GithubIcon className="size-5" />
</Link> </Link>
{template.links.website && ( )}
{template?.links?.website && (
<Link <Link
href={template.links.website} href={template?.links?.website}
target="_blank" target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
<Globe className="size-5" /> <Globe className="size-5" />
</Link> </Link>
)} )}
{template.links.docs && ( {template?.links?.docs && (
<Link <Link
href={template.links.docs} href={template?.links?.docs}
target="_blank" target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors" className="text-muted-foreground hover:text-foreground transition-colors"
> >
@@ -419,7 +421,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will create an application from the{" "} This will create an application from the{" "}
{template.name} template and add it to your {template?.name} template and add it to your
project. project.
</AlertDialogDescription> </AlertDialogDescription>

View File

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

View File

@@ -0,0 +1,172 @@
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,8 +31,13 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const AddProjectSchema = z.object({ const AddProjectSchema = z.object({
name: z.string().min(1, { name: z
.string()
.min(1, {
message: "Name is required", message: "Name is required",
})
.regex(/^[a-zA-Z]/, {
message: "Project name cannot start with a number",
}), }),
description: z.string().optional(), description: z.string().optional(),
}); });
@@ -97,18 +102,6 @@ export const HandleProject = ({ projectId }: Props) => {
); );
}); });
}; };
// useEffect(() => {
// const getUsers = async () => {
// const users = await authClient.admin.listUsers({
// query: {
// limit: 100,
// },
// });
// console.log(users);
// };
// getUsers();
// });
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>

View File

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

View File

@@ -33,7 +33,11 @@ const DockerProviderSchema = z.object({
return Number.isNaN(parsed) ? null : parsed; return Number.isNaN(parsed) ? null : parsed;
} }
return null; return null;
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()), }, z
.number()
.gte(0, "Range must be 0 - 65535")
.lte(65535, "Range must be 0 - 65535")
.nullable()),
}); });
type DockerProvider = z.infer<typeof DockerProviderSchema>; type DockerProvider = z.infer<typeof DockerProviderSchema>;

View File

@@ -1,10 +1,10 @@
import { api } from "@/utils/api";
import { import {
type ChartConfig, type ChartConfig,
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from "@/components/ui/chart"; } from "@/components/ui/chart";
import { api } from "@/utils/api";
import { import {
Area, Area,
AreaChart, AreaChart,

View File

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

View File

@@ -1,14 +1,22 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { toast } from "sonner";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
Select, Select,
@@ -17,22 +25,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } 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 { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import copy from "copy-to-clipboard";
import { CodeEditor } from "@/components/shared/code-editor";
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import { import {
DialogDescription, DialogDescription,
@@ -6,7 +7,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react"; import { CopyIcon, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {
@@ -14,18 +15,26 @@ interface Props {
} }
export const AddManager = ({ serverId }: Props) => { export const AddManager = ({ serverId }: Props) => {
const { data } = api.cluster.addManager.useQuery({ serverId }); const { data, isLoading, error, isError } = api.cluster.addManager.useQuery({
serverId,
});
return ( return (
<> <>
<div> <CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0">
<DialogHeader> <DialogHeader>
<DialogTitle>Add a new manager</DialogTitle> <DialogTitle>Add a new manager</DialogTitle>
<DialogDescription>Add a new manager</DialogDescription> <DialogDescription>Add a new manager</DialogDescription>
</DialogHeader> </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"> <div className="flex flex-col gap-2.5 text-sm">
<span>1. Go to your new server and run the following command</span> <span>
1. Go to your new server and run the following command
</span>
<span className="bg-muted rounded-lg p-2 flex justify-between"> <span className="bg-muted rounded-lg p-2 flex justify-between">
curl https://get.docker.com | sh -s -- --version {data?.version} curl https://get.docker.com | sh -s -- --version {data?.version}
<button <button
@@ -48,6 +57,7 @@ export const AddManager = ({ serverId }: Props) => {
2. Run the following command to add the node(manager) to your 2. Run the following command to add the node(manager) to your
cluster cluster
</span> </span>
<span className="bg-muted rounded-lg p-2 flex"> <span className="bg-muted rounded-lg p-2 flex">
{data?.command} {data?.command}
<button <button
@@ -62,8 +72,9 @@ export const AddManager = ({ serverId }: Props) => {
</button> </button>
</span> </span>
</div> </div>
</>
)}
</CardContent> </CardContent>
</div>
</> </>
); );
}; };

View File

@@ -17,7 +17,7 @@ export const ShowNodesModal = ({ serverId }: Props) => {
className="w-full cursor-pointer " className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()}
> >
Show Nodes Show Swarm Nodes
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen "> <DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">

View File

@@ -35,9 +35,9 @@ import { api } from "@/utils/api";
import { import {
Boxes, Boxes,
HelpCircle, HelpCircle,
Loader2,
LockIcon, LockIcon,
MoreHorizontal, MoreHorizontal,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AddNode } from "./add-node"; import { AddNode } from "./add-node";
@@ -144,7 +144,7 @@ export const ShowNodes = ({ serverId }: Props) => {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<ShowNodeData data={node} /> <ShowNodeData data={node} />
{node?.ManagerStatus?.Leader && ( {!node?.ManagerStatus?.Leader && (
<DialogAction <DialogAction
title="Delete Node" title="Delete Node"
description="Are you sure you want to delete this node from the cluster?" description="Are you sure you want to delete this node from the cluster?"

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { CardContent } from "@/components/ui/card"; import { CardContent } from "@/components/ui/card";
import { import {
DialogDescription, DialogDescription,
@@ -6,7 +7,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { CopyIcon } from "lucide-react"; import { CopyIcon, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {
@@ -14,15 +15,21 @@ interface Props {
} }
export const AddWorker = ({ serverId }: Props) => { export const AddWorker = ({ serverId }: Props) => {
const { data } = api.cluster.addWorker.useQuery({ serverId }); const { data, isLoading, error, isError } = api.cluster.addWorker.useQuery({
serverId,
});
return ( return (
<div> <CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0">
<DialogHeader> <DialogHeader>
<DialogTitle>Add a new worker</DialogTitle> <DialogTitle>Add a new worker</DialogTitle>
<DialogDescription>Add a new worker</DialogDescription> <DialogDescription>Add a new worker</DialogDescription>
</DialogHeader> </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"> <div className="flex flex-col gap-2.5 text-sm">
<span>1. Go to your new server and run the following command</span> <span>1. Go to your new server and run the following command</span>
<span className="bg-muted rounded-lg p-2 flex justify-between"> <span className="bg-muted rounded-lg p-2 flex justify-between">
@@ -44,7 +51,8 @@ export const AddWorker = ({ serverId }: Props) => {
<div className="flex flex-col gap-2.5 text-sm"> <div className="flex flex-col gap-2.5 text-sm">
<span> <span>
2. Run the following command to add the node(worker) to your cluster 2. Run the following command to add the node(worker) to your
cluster
</span> </span>
<span className="bg-muted rounded-lg p-2 flex"> <span className="bg-muted rounded-lg p-2 flex">
@@ -61,7 +69,8 @@ export const AddWorker = ({ serverId }: Props) => {
</button> </button>
</span> </span>
</div> </div>
</>
)}
</CardContent> </CardContent>
</div>
); );
}; };

View File

@@ -0,0 +1,286 @@
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

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

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ const PasswordSchema = z.object({
password: z.string().min(8, { password: z.string().min(8, {
message: "Password is required", message: "Password is required",
}), }),
issuer: z.string().optional(),
}); });
const PinSchema = z.object({ const PinSchema = z.object({
@@ -60,12 +61,86 @@ export const Enable2FA = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [step, setStep] = useState<"password" | "verify">("password"); const [step, setStep] = useState<"password" | "verify">("password");
const [isPasswordLoading, setIsPasswordLoading] = useState(false); const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const [otpValue, setOtpValue] = useState("");
const 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) => { const handlePasswordSubmit = async (formData: PasswordForm) => {
setIsPasswordLoading(true); setIsPasswordLoading(true);
try { try {
const { data: enableData, error } = await authClient.twoFactor.enable({ const { data: enableData, error } = await authClient.twoFactor.enable({
password: formData.password, password: formData.password,
issuer: formData.issuer,
}); });
if (!enableData) { if (!enableData) {
@@ -103,75 +178,6 @@ export const Enable2FA = () => {
} }
}; };
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 ( return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -217,6 +223,27 @@ export const Enable2FA = () => {
</FormItem> </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 <Button
type="submit" type="submit"
className="w-full" className="w-full"
@@ -228,11 +255,7 @@ export const Enable2FA = () => {
</Form> </Form>
) : ( ) : (
<Form {...pinForm}> <Form {...pinForm}>
<form <form onSubmit={handleVerifySubmit} className="space-y-6">
id="pin-form"
onSubmit={pinForm.handleSubmit(handleVerifySubmit)}
className="space-y-6"
>
<div className="flex flex-col gap-6 justify-center items-center"> <div className="flex flex-col gap-6 justify-center items-center">
{data?.qrCodeUrl ? ( {data?.qrCodeUrl ? (
<> <>
@@ -284,14 +307,14 @@ export const Enable2FA = () => {
)} )}
</div> </div>
<FormField <div className="flex flex-col justify-center items-center">
control={pinForm.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col justify-center items-center">
<FormLabel>Verification Code</FormLabel> <FormLabel>Verification Code</FormLabel>
<FormControl> <InputOTP
<InputOTP maxLength={6} {...field}> maxLength={6}
value={otpValue}
onChange={setOtpValue}
autoComplete="off"
>
<InputOTPGroup> <InputOTPGroup>
<InputOTPSlot index={0} /> <InputOTPSlot index={0} />
<InputOTPSlot index={1} /> <InputOTPSlot index={1} />
@@ -301,19 +324,16 @@ export const Enable2FA = () => {
<InputOTPSlot index={5} /> <InputOTPSlot index={5} />
</InputOTPGroup> </InputOTPGroup>
</InputOTP> </InputOTP>
</FormControl>
<FormDescription> <FormDescription>
Enter the 6-digit code from your authenticator app Enter the 6-digit code from your authenticator app
</FormDescription> </FormDescription>
<FormMessage /> </div>
</FormItem>
)}
/>
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
isLoading={isPasswordLoading} isLoading={isPasswordLoading}
disabled={otpValue.length !== 6}
> >
Enable 2FA Enable 2FA
</Button> </Button>

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ import { useTranslation } from "next-i18next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { toast } from "sonner"; import { toast } from "sonner";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal"; import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions"; import { ShowServerActions } from "./actions/show-server-actions";
import { HandleServers } from "./handle-servers"; import { HandleServers } from "./handle-servers";
@@ -42,7 +43,6 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal"; import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal"; import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription"; import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
export const ShowServers = () => { export const ShowServers = () => {
const { t } = useTranslation("settings"); const { t } = useTranslation("settings");

View File

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

View File

@@ -9,6 +9,7 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -22,6 +23,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { GlobeIcon } from "lucide-react"; import { GlobeIcon } from "lucide-react";
@@ -33,11 +35,19 @@ import { z } from "zod";
const addServerDomain = z const addServerDomain = z
.object({ .object({
domain: z.string().min(1, { message: "URL is required" }), domain: z.string(),
letsEncryptEmail: z.string(), letsEncryptEmail: z.string(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]), certificateType: z.enum(["letsencrypt", "none", "custom"]),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.https && !data.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
message: "Required",
});
}
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) { if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@@ -61,15 +71,18 @@ export const WebDomain = () => {
domain: "", domain: "",
certificateType: "none", certificateType: "none",
letsEncryptEmail: "", letsEncryptEmail: "",
https: false,
}, },
resolver: zodResolver(addServerDomain), resolver: zodResolver(addServerDomain),
}); });
const https = form.watch("https");
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset({
domain: data?.user?.host || "", domain: data?.user?.host || "",
certificateType: data?.user?.certificateType, certificateType: data?.user?.certificateType,
letsEncryptEmail: data?.user?.letsEncryptEmail || "", letsEncryptEmail: data?.user?.letsEncryptEmail || "",
https: data?.user?.https || false,
}); });
} }
}, [form, form.reset, data]); }, [form, form.reset, data]);
@@ -79,6 +92,7 @@ export const WebDomain = () => {
host: data.domain, host: data.domain,
letsEncryptEmail: data.letsEncryptEmail, letsEncryptEmail: data.letsEncryptEmail,
certificateType: data.certificateType, certificateType: data.certificateType,
https: data.https,
}) })
.then(async () => { .then(async () => {
await refetch(); await refetch();
@@ -153,6 +167,28 @@ 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>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{https && (
<FormField <FormField
control={form.control} control={form.control}
name="certificateType" name="certificateType"
@@ -193,6 +229,7 @@ export const WebDomain = () => {
); );
}} }}
/> />
)}
<div className="flex w-full justify-end col-span-2"> <div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isLoading} type="submit">

View File

@@ -159,9 +159,15 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<Input <Input
type="number" type="number"
{...field} {...field}
onChange={(e) => onChange={(e) => {
field.onChange(Number(e.target.value)) const value = e.target.value;
} field.onChange(
value === ""
? undefined
: Number(value),
);
}}
value={field.value || ""}
className="w-full dark:bg-black" className="w-full dark:bg-black"
placeholder="e.g. 8080" placeholder="e.g. 8080"
/> />
@@ -185,9 +191,15 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
<Input <Input
type="number" type="number"
{...field} {...field}
onChange={(e) => onChange={(e) => {
field.onChange(Number(e.target.value)) const value = e.target.value;
} field.onChange(
value === ""
? undefined
: Number(value),
);
}}
value={field.value || ""}
className="w-full dark:bg-black" className="w-full dark:bg-black"
placeholder="e.g. 80" placeholder="e.g. 80"
/> />

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -22,7 +23,6 @@ import dynamic from "next/dynamic";
import type React from "react"; import type React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { badgeStateColor } from "../../application/logs/show"; import { badgeStateColor } from "../../application/logs/show";
import { Badge } from "@/components/ui/badge";
export const DockerLogsId = dynamic( export const DockerLogsId = dynamic(
() => () =>

View File

@@ -12,7 +12,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { DatabaseIcon, AlertTriangle } from "lucide-react"; import { AlertTriangle, DatabaseIcon } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
interface Props { interface Props {

View File

@@ -1,6 +1,6 @@
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources"; import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes"; 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"; import { RebuildDatabase } from "./rebuild-database";
interface Props { interface Props {

View File

@@ -238,6 +238,41 @@ 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) => { export const DockerIcon = ({ className }: Props) => {
return ( return (
<svg <svg

View File

@@ -1,10 +1,10 @@
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import type { IUpdateData } from "@dokploy/server/index"; import type { IUpdateData } from "@dokploy/server/index";
import { Download } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import UpdateServer from "../dashboard/settings/web-server/update-server"; import UpdateServer from "../dashboard/settings/web-server/update-server";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { Download } from "lucide-react";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,

View File

@@ -120,17 +120,6 @@ export const UserNav = () => {
Docker Docker
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{data?.role === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
router.push("/dashboard/settings");
}}
>
Settings
</DropdownMenuItem>
)}
</> </>
) : ( ) : (
<> <>
@@ -144,17 +133,6 @@ export const UserNav = () => {
Servers Servers
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{data?.role === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
router.push("/dashboard/settings");
}}
>
Settings
</DropdownMenuItem>
)}
</> </>
)} )}
</DropdownMenuGroup> </DropdownMenuGroup>

View File

@@ -37,7 +37,9 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
)} )}
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
{_index + 1 < list.length && <BreadcrumbSeparator className="block" />} {_index + 1 < list.length && (
<BreadcrumbSeparator className="block" />
)}
</Fragment> </Fragment>
))} ))}
</BreadcrumbList> </BreadcrumbList>

View File

@@ -3,18 +3,18 @@ import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml"; import { yaml } from "@codemirror/lang-yaml";
import { StreamLanguage } from "@codemirror/language"; import { StreamLanguage } from "@codemirror/language";
import {
type Completion,
type CompletionContext,
type CompletionResult,
autocompletion,
} from "@codemirror/autocomplete";
import { properties } from "@codemirror/legacy-modes/mode/properties"; import { properties } from "@codemirror/legacy-modes/mode/properties";
import { shell } from "@codemirror/legacy-modes/mode/shell"; import { shell } from "@codemirror/legacy-modes/mode/shell";
import { EditorView } from "@codemirror/view"; import { EditorView } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github"; import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror"; import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import {
autocompletion,
type CompletionContext,
type CompletionResult,
type Completion,
} from "@codemirror/autocomplete";
// Docker Compose completion options // Docker Compose completion options
const dockerComposeServices = [ const dockerComposeServices = [

View File

@@ -1,9 +1,9 @@
import type * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import type * as React from "react";
import { DayPicker } from "react-day-picker"; import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type CalendarProps = React.ComponentProps<typeof DayPicker>; export type CalendarProps = React.ComponentProps<typeof DayPicker>;

View File

@@ -39,7 +39,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, InputProps>(
className={cn("text-left", className)} className={cn("text-left", className)}
ref={ref} ref={ref}
{...props} {...props}
value={props.value === undefined || props.value === "" ? "" : String(props.value)} value={props.value === undefined ? undefined : String(props.value)}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
if (value === "") { if (value === "") {
@@ -60,21 +60,6 @@ const NumberInput = React.forwardRef<HTMLInputElement, InputProps>(
} }
} }
}} }}
onBlur={(e) => {
// If input is empty, make 0 when focus is lost
if (e.target.value === "") {
const syntheticEvent = {
...e,
target: {
...e.target,
value: "0",
},
};
props.onChange?.(
syntheticEvent as unknown as React.ChangeEvent<HTMLInputElement>,
);
}
}}
/> />
); );
}, },

View File

@@ -0,0 +1,31 @@
ALTER TYPE "public"."sourceType" ADD VALUE 'gitea' BEFORE 'drop';--> statement-breakpoint
ALTER TYPE "public"."sourceTypeCompose" ADD VALUE 'gitea' BEFORE 'raw';--> statement-breakpoint
ALTER TYPE "public"."gitProviderType" ADD VALUE 'gitea';--> statement-breakpoint
CREATE TABLE "gitea" (
"giteaId" text PRIMARY KEY NOT NULL,
"giteaUrl" text DEFAULT 'https://gitea.com' NOT NULL,
"redirect_uri" text,
"client_id" text,
"client_secret" text,
"gitProviderId" text NOT NULL,
"gitea_username" text,
"access_token" text,
"refresh_token" text,
"expires_at" integer,
"scopes" text DEFAULT 'repo,repo:status,read:user,read:org',
"last_authenticated_at" integer
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaProjectId" integer;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaRepository" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaOwner" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaBranch" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaBuildPath" text DEFAULT '/';--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "giteaId" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "giteaRepository" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "giteaOwner" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "giteaBranch" text;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "giteaId" text;--> statement-breakpoint
ALTER TABLE "gitea" ADD CONSTRAINT "gitea_gitProviderId_git_provider_gitProviderId_fk" FOREIGN KEY ("gitProviderId") REFERENCES "public"."git_provider"("gitProviderId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_giteaId_gitea_giteaId_fk" FOREIGN KEY ("giteaId") REFERENCES "public"."gitea"("giteaId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "compose" ADD CONSTRAINT "compose_giteaId_gitea_giteaId_fk" FOREIGN KEY ("giteaId") REFERENCES "public"."gitea"("giteaId") ON DELETE set null ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "application" DROP COLUMN "giteaProjectId";

View File

@@ -0,0 +1 @@
ALTER TABLE "gitea" DROP COLUMN "gitea_username";

View File

@@ -0,0 +1,2 @@
ALTER TABLE "backup" ADD COLUMN "userId" text;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TYPE "public"."databaseType" ADD VALUE 'web-server';

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -554,6 +554,48 @@
"when": 1742112194375, "when": 1742112194375,
"tag": "0078_uneven_omega_sentinel", "tag": "0078_uneven_omega_sentinel",
"breakpoints": true "breakpoints": true
},
{
"idx": 79,
"version": "7",
"when": 1742281690186,
"tag": "0079_bizarre_wendell_rand",
"breakpoints": true
},
{
"idx": 80,
"version": "7",
"when": 1743280866402,
"tag": "0080_sleepy_sinister_six",
"breakpoints": true
},
{
"idx": 81,
"version": "7",
"when": 1743281254393,
"tag": "0081_lovely_mentallo",
"breakpoints": true
},
{
"idx": 82,
"version": "7",
"when": 1743287689974,
"tag": "0082_clean_mandarin",
"breakpoints": true
},
{
"idx": 83,
"version": "7",
"when": 1743288371413,
"tag": "0083_parallel_stranger",
"breakpoints": true
},
{
"idx": 84,
"version": "7",
"when": 1743923992280,
"tag": "0084_thin_iron_lad",
"breakpoints": true
} }
] ]
} }

View File

@@ -0,0 +1,552 @@
{
"version": "5",
"dialect": "pg",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1713262741218,
"tag": "0000_reflective_puck",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1713761637676,
"tag": "0001_striped_tattoo",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1713763492341,
"tag": "0002_ambiguous_carlie_cooper",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1713947141424,
"tag": "0003_square_lightspeed",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1714004732716,
"tag": "0004_nice_tenebrous",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1715551130605,
"tag": "0005_cute_terror",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1715563165991,
"tag": "0006_oval_jimmy_woo",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1715563497100,
"tag": "0007_cute_guardsmen",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1715564143641,
"tag": "0008_lazy_sage",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1715564774423,
"tag": "0009_majestic_spencer_smythe",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1715574037832,
"tag": "0010_lean_black_widow",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1715574230599,
"tag": "0011_petite_calypso",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1716015716708,
"tag": "0012_chubby_umar",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1716076179443,
"tag": "0013_blushing_starjammers",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1716715367982,
"tag": "0014_same_hammerhead",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1717564517104,
"tag": "0015_fearless_callisto",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1719109196484,
"tag": "0016_chunky_leopardon",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1719547174326,
"tag": "0017_minor_post",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1719928377858,
"tag": "0018_careful_killmonger",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1721110706912,
"tag": "0019_heavy_freak",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1721363861686,
"tag": "0020_fantastic_slapstick",
"breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1721370423752,
"tag": "0021_premium_sebastian_shaw",
"breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1721531163852,
"tag": "0022_warm_colonel_america",
"breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1721542782659,
"tag": "0023_icy_maverick",
"breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1721603595092,
"tag": "0024_dapper_supernaut",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1721633853118,
"tag": "0025_lying_mephisto",
"breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1721979220929,
"tag": "0026_known_dormammu",
"breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1722445099203,
"tag": "0027_red_lady_bullseye",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1722503439951,
"tag": "0028_jittery_eternity",
"breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1722578386823,
"tag": "0029_colossal_zodiak",
"breakpoints": true
},
{
"idx": 30,
"version": "6",
"when": 1723608499147,
"tag": "0030_little_kabuki",
"breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1723701656243,
"tag": "0031_steep_vulture",
"breakpoints": true
},
{
"idx": 32,
"version": "6",
"when": 1723705257806,
"tag": "0032_flashy_shadow_king",
"breakpoints": true
},
{
"idx": 33,
"version": "6",
"when": 1725250322137,
"tag": "0033_white_hawkeye",
"breakpoints": true
},
{
"idx": 34,
"version": "6",
"when": 1725256397019,
"tag": "0034_aspiring_secret_warriors",
"breakpoints": true
},
{
"idx": 35,
"version": "6",
"when": 1725429324584,
"tag": "0035_cool_gravity",
"breakpoints": true
},
{
"idx": 36,
"version": "6",
"when": 1725519351871,
"tag": "0036_tired_ronan",
"breakpoints": true
},
{
"idx": 37,
"version": "6",
"when": 1726988289562,
"tag": "0037_legal_namor",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1727942090102,
"tag": "0038_rapid_landau",
"breakpoints": true
},
{
"idx": 39,
"version": "6",
"when": 1728021127765,
"tag": "0039_many_tiger_shark",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1728780577084,
"tag": "0040_graceful_wolfsbane",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1729667438853,
"tag": "0041_huge_bruce_banner",
"breakpoints": true
},
{
"idx": 42,
"version": "6",
"when": 1729984439862,
"tag": "0042_fancy_havok",
"breakpoints": true
},
{
"idx": 43,
"version": "6",
"when": 1731873965888,
"tag": "0043_closed_naoko",
"breakpoints": true
},
{
"idx": 44,
"version": "6",
"when": 1731875539532,
"tag": "0044_sour_true_believers",
"breakpoints": true
},
{
"idx": 45,
"version": "6",
"when": 1732644181718,
"tag": "0045_smiling_blur",
"breakpoints": true
},
{
"idx": 46,
"version": "6",
"when": 1732851191048,
"tag": "0046_purple_sleeper",
"breakpoints": true
},
{
"idx": 47,
"version": "6",
"when": 1733599090582,
"tag": "0047_tidy_revanche",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1733599163710,
"tag": "0048_flat_expediter",
"breakpoints": true
},
{
"idx": 49,
"version": "6",
"when": 1733628762978,
"tag": "0049_dark_leopardon",
"breakpoints": true
},
{
"idx": 50,
"version": "6",
"when": 1733889104203,
"tag": "0050_nappy_wrecker",
"breakpoints": true
},
{
"idx": 51,
"version": "6",
"when": 1734241482851,
"tag": "0051_hard_gorgon",
"breakpoints": true
},
{
"idx": 52,
"version": "6",
"when": 1734809337308,
"tag": "0052_bumpy_luckman",
"breakpoints": true
},
{
"idx": 53,
"version": "6",
"when": 1735118844878,
"tag": "0053_broken_kulan_gath",
"breakpoints": true
},
{
"idx": 54,
"version": "6",
"when": 1736669421560,
"tag": "0054_nervous_spencer_smythe",
"breakpoints": true
},
{
"idx": 55,
"version": "6",
"when": 1736669623831,
"tag": "0055_next_serpent_society",
"breakpoints": true
},
{
"idx": 56,
"version": "6",
"when": 1736789918294,
"tag": "0056_majestic_skaar",
"breakpoints": true
},
{
"idx": 57,
"version": "6",
"when": 1737306063563,
"tag": "0057_tricky_living_tribunal",
"breakpoints": true
},
{
"idx": 58,
"version": "6",
"when": 1737612903012,
"tag": "0058_brown_sharon_carter",
"breakpoints": true
},
{
"idx": 59,
"version": "6",
"when": 1737615160768,
"tag": "0059_striped_bill_hollister",
"breakpoints": true
},
{
"idx": 60,
"version": "6",
"when": 1737929896838,
"tag": "0060_disable-aggressive-cache",
"breakpoints": true
},
{
"idx": 61,
"version": "7",
"when": 1738481304953,
"tag": "0061_many_molten_man",
"breakpoints": true
},
{
"idx": 62,
"version": "7",
"when": 1738482795112,
"tag": "0062_slippery_white_tiger",
"breakpoints": true
},
{
"idx": 63,
"version": "7",
"when": 1738522845992,
"tag": "0063_panoramic_dreadnoughts",
"breakpoints": true
},
{
"idx": 64,
"version": "7",
"when": 1738564387043,
"tag": "0064_previous_agent_brand",
"breakpoints": true
},
{
"idx": 65,
"version": "7",
"when": 1739087857244,
"tag": "0065_daily_zaladane",
"breakpoints": true
},
{
"idx": 66,
"version": "7",
"when": 1739426913392,
"tag": "0066_yielding_echo",
"breakpoints": true
},
{
"idx": 67,
"version": "7",
"when": 1740892043121,
"tag": "0067_condemned_sugar_man",
"breakpoints": true
},
{
"idx": 68,
"version": "7",
"when": 1740897756774,
"tag": "0068_complex_rhino",
"breakpoints": true
},
{
"idx": 69,
"version": "7",
"when": 1741152916611,
"tag": "0069_legal_bill_hollister",
"breakpoints": true
},
{
"idx": 70,
"version": "7",
"when": 1741322697251,
"tag": "0070_useful_serpent_society",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1741559743256,
"tag": "0071_flimsy_plazm",
"breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1741593124105,
"tag": "0072_low_redwing",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1741645208694,
"tag": "0073_dark_tigra",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1741673569715,
"tag": "0074_military_miss_america",
"breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1742018928109,
"tag": "0075_wild_xorn",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1742237840762,
"tag": "0076_tough_iron_patriot",
"breakpoints": true
},
{
"idx": 77,
"version": "7",
"when": 1742238314349,
"tag": "0077_mature_tomorrow_man",
"breakpoints": true
}
]
}

View File

@@ -1,23 +1,27 @@
/**
* Sorted list based off of population of the country / speakers of the language.
*/
export const Languages = { export const Languages = {
english: { code: "en", name: "English" }, english: { code: "en", name: "English" },
spanish: { code: "es", name: "Español" },
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
portuguese: { code: "pt-br", name: "Português" },
russian: { code: "ru", name: "Русский" },
japanese: { code: "ja", name: "日本語" },
german: { code: "de", name: "Deutsch" },
korean: { code: "ko", name: "한국어" },
french: { code: "fr", name: "Français" },
turkish: { code: "tr", name: "Türkçe" },
italian: { code: "it", name: "Italiano" },
polish: { code: "pl", name: "Polski" }, polish: { code: "pl", name: "Polski" },
ukrainian: { code: "uk", name: "Українська" }, ukrainian: { code: "uk", name: "Українська" },
russian: { code: "ru", name: "Русский" },
french: { code: "fr", name: "Français" },
german: { code: "de", name: "Deutsch" },
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
turkish: { code: "tr", name: "Türkçe" },
kazakh: { code: "kz", name: "Қазақ" },
persian: { code: "fa", name: "فارسی" }, persian: { code: "fa", name: "فارسی" },
korean: { code: "ko", name: "한국어" }, dutch: { code: "nl", name: "Nederlands" },
portuguese: { code: "pt-br", name: "Português" }, indonesian: { code: "id", name: "Bahasa Indonesia" },
italian: { code: "it", name: "Italiano" }, kazakh: { code: "kz", name: "Қазақ" },
japanese: { code: "ja", name: "日本語" },
spanish: { code: "es", name: "Español" },
norwegian: { code: "no", name: "Norsk" }, norwegian: { code: "no", name: "Norsk" },
azerbaijani: { code: "az", name: "Azərbaycan" }, azerbaijani: { code: "az", name: "Azərbaycan" },
indonesian: { code: "id", name: "Bahasa Indonesia" },
malayalam: { code: "ml", name: "മലയാളം" }, malayalam: { code: "ml", name: "മലയാളം" },
}; };

View File

@@ -1,32 +0,0 @@
// middleware.ts
import { verifyRequestOrigin } from "lucia";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function middleware(request: NextRequest): Promise<NextResponse> {
if (request.method === "GET") {
return NextResponse.next();
}
const originHeader = request.headers.get("Origin");
const hostHeader = request.headers.get("Host");
if (
!originHeader ||
!hostHeader ||
!verifyRequestOrigin(originHeader, [hostHeader])
) {
return new NextResponse(null, {
status: 403,
});
}
return NextResponse.next();
}
export const config = {
matcher: [
// Don't handle HMR requests for the dev server we rewrite to
"/settings",
"/dashboard/(.*)",
"/invitation",
],
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.20.5", "version": "v0.21.7",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -53,7 +53,6 @@
"@dokploy/trpc-openapi": "0.0.4", "@dokploy/trpc-openapi": "0.0.4",
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@lucia-auth/adapter-drizzle": "1.0.7",
"@octokit/auth-app": "^6.0.4", "@octokit/auth-app": "^6.0.4",
"@octokit/webhooks": "^13.2.7", "@octokit/webhooks": "^13.2.7",
"@radix-ui/react-accordion": "1.1.2", "@radix-ui/react-accordion": "1.1.2",
@@ -93,7 +92,7 @@
"adm-zip": "^0.5.14", "adm-zip": "^0.5.14",
"ai": "^4.0.23", "ai": "^4.0.23",
"bcrypt": "5.1.1", "bcrypt": "5.1.1",
"better-auth": "1.2.4", "better-auth": "1.2.6",
"bl": "6.0.11", "bl": "6.0.11",
"boxen": "^7.1.1", "boxen": "^7.1.1",
"bullmq": "5.4.2", "bullmq": "5.4.2",
@@ -113,11 +112,10 @@
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"lucia": "^3.0.1",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"micromatch": "4.0.8", "micromatch": "4.0.8",
"nanoid": "3", "nanoid": "3",
"next": "^15.0.1", "next": "^15.2.4",
"next-i18next": "^15.3.1", "next-i18next": "^15.3.1",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"node-os-utils": "1.3.7", "node-os-utils": "1.3.7",
@@ -152,7 +150,8 @@
"ws": "8.16.0", "ws": "8.16.0",
"xterm-addon-fit": "^0.8.0", "xterm-addon-fit": "^0.8.0",
"zod": "^3.23.4", "zod": "^3.23.4",
"zod-form-data": "^2.0.2" "zod-form-data": "^2.0.2",
"toml": "3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.5", "@types/adm-zip": "^0.5.5",

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