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,
});
}
} }
}, [form.formState.isSubmitSuccessful, form.reset, data, form]); }, [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,193 +208,143 @@ 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> <RadioGroup
<RadioGroup onValueChange={field.onChange}
onValueChange={field.onChange} value={field.value}
value={field.value} className="flex flex-col space-y-1"
className="flex flex-col space-y-1" >
> {Object.entries(buildTypeDisplayMap).map(
<FormItem className="flex items-center space-x-3 space-y-0"> ([value, label]) => (
<FormControl> <FormItem
<RadioGroupItem value="dockerfile" /> key={value}
</FormControl> className="flex items-center space-x-3 space-y-0"
<FormLabel className="font-normal"> >
Dockerfile <FormControl>
</FormLabel> <RadioGroupItem value={value} />
</FormItem> </FormControl>
<FormItem className="flex items-center space-x-3 space-y-0"> <FormLabel className="font-normal">
<FormControl> {label}
<RadioGroupItem value="railpack" /> {value === BuildType.railpack && (
</FormControl> <Badge className="ml-2 px-1 text-xs">New</Badge>
<FormLabel className="font-normal"> )}
Railpack{" "} </FormLabel>
<Badge className="ml-1 text-xs px-1">New</Badge> </FormItem>
</FormLabel> ),
</FormItem> )}
<FormItem className="flex items-center space-x-3 space-y-0"> </RadioGroup>
<FormControl> </FormControl>
<RadioGroupItem value="nixpacks" /> <FormMessage />
</FormControl> </FormItem>
<FormLabel className="font-normal"> )}
Nixpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="heroku_buildpacks" />
</FormControl>
<FormLabel className="font-normal">
Heroku Buildpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="paketo_buildpacks" />
</FormControl>
<FormLabel className="font-normal">
Paketo Buildpacks
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="static" />
</FormControl>
<FormLabel className="font-normal">Static</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/> />
{buildType === "heroku_buildpacks" && ( {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 />
</FormItem>
<FormMessage /> )}
</FormItem>
);
}}
/> />
)} )}
{buildType === "dockerfile" && ( {buildType === BuildType.dockerfile && (
<> <>
<FormField <FormField
control={form.control} control={form.control}
name="dockerfile" name="dockerfile"
render={({ field }) => { render={({ field }) => (
return (
<FormItem>
<FormLabel>Docker File</FormLabel>
<FormControl>
<Input
placeholder={"Path of your docker file"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="dockerContextPath"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Docker Context Path</FormLabel>
<FormControl>
<Input
placeholder={
"Path of your docker context default: ."
}
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="dockerBuildStage"
render={({ field }) => {
return (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Docker Build Stage</FormLabel>
<FormDescription>
Allows you to target a specific stage in a
Multi-stage Dockerfile. If empty, Docker defaults to
build the last defined stage.
</FormDescription>
</div>
<FormControl>
<Input
placeholder={"E.g. production"}
{...field}
value={field.value ?? ""}
/>
</FormControl>
</FormItem>
);
}}
/>
</>
)}
{buildType === "nixpacks" && (
<FormField
control={form.control}
name="publishDirectory"
render={({ field }) => {
return (
<FormItem> <FormItem>
<div className="space-y-0.5"> <FormLabel>Docker File</FormLabel>
<FormLabel>Publish Directory</FormLabel>
<FormDescription>
Allows you to serve a single directory via NGINX after
the build phase. Useful if the final build assets
should be served as a static site.
</FormDescription>
</div>
<FormControl> <FormControl>
<Input <Input
placeholder={"Publish Directory"} placeholder="Path of your docker file"
{...field} {...field}
value={field.value ?? ""} value={field.value ?? ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); )}
}} />
<FormField
control={form.control}
name="dockerContextPath"
render={({ field }) => (
<FormItem>
<FormLabel>Docker Context Path</FormLabel>
<FormControl>
<Input
placeholder="Path of your docker context (default: .)"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="dockerBuildStage"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Docker Build Stage</FormLabel>
<FormDescription>
Allows you to target a specific stage in a Multi-stage
Dockerfile. If empty, Docker defaults to build the
last defined stage.
</FormDescription>
</div>
<FormControl>
<Input
placeholder="E.g. production"
{...field}
value={field.value ?? ""}
/>
</FormControl>
</FormItem>
)}
/>
</>
)}
{buildType === BuildType.nixpacks && (
<FormField
control={form.control}
name="publishDirectory"
render={({ field }) => (
<FormItem>
<div className="space-y-0.5">
<FormLabel>Publish Directory</FormLabel>
<FormDescription>
Allows you to serve a single directory via NGINX after
the build phase. Useful if the final build assets should
be served as a static site.
</FormDescription>
</div>
<FormControl>
<Input
placeholder="Publish Directory"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
)} )}
<div className="flex w-full justify-end"> <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");
@@ -55,7 +65,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
setSab(e as TabState); setSab(e as TabState);
}} }}
> >
<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-7 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden"> <TabsList className="md:grid md:w-fit md:grid-cols-7 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
<TabsTrigger <TabsTrigger
value="github" value="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>
@@ -54,21 +58,21 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
setSab(e as TabState); setSab(e as TabState);
}} }}
> >
<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"
@@ -89,11 +98,12 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
value="raw" value="raw"
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border" className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
> >
<CodeIcon className="size-4 " /> <CodeIcon className="size-4" />
Raw Raw
</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,7 +112,11 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
? { ? {
mongoId: databaseId, mongoId: databaseId,
} }
: undefined; : databaseType === "web-server"
? {
userId: databaseId,
}
: undefined;
await createBackup({ await createBackup({
destinationId: data.destinationId, destinationId: data.destinationId,
@@ -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,16 +294,21 @@ 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>
); );
}} }}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="enabled" name="enabled"

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

@@ -27,145 +27,149 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const DockerProviderSchema = z.object({ const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => { externalPort: z.preprocess((a) => {
if (a !== null) { if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10); const parsed = Number.parseInt(z.string().parse(a), 10);
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>;
interface Props { interface Props {
mariadbId: string; mariadbId: string;
} }
export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => { export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId }); const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip; const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
}); });
useEffect(() => { useEffect(() => {
if (data?.externalPort) { if (data?.externalPort) {
form.reset({ form.reset({
externalPort: data.externalPort, externalPort: data.externalPort,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => { const onSubmit = async (values: DockerProvider) => {
await mutateAsync({ await mutateAsync({
externalPort: values.externalPort, externalPort: values.externalPort,
mariadbId, mariadbId,
}) })
.then(async () => { .then(async () => {
toast.success("External Port updated"); toast.success("External Port updated");
await refetch(); await refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error saving the external port"); toast.error("Error saving the external port");
}); });
}; };
useEffect(() => { useEffect(() => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`; return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
}, [ }, [
data?.appName, data?.appName,
data?.externalPort, data?.externalPort,
data?.databasePassword, data?.databasePassword,
form, form,
data?.databaseName, data?.databaseName,
data?.databaseUser, data?.databaseUser,
getIp, getIp,
]); ]);
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle> <CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription> <CardDescription>
In order to make the database reachable trought internet is In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another required to set a port, make sure the port is not used by another
application or database application or database
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-col gap-4"> <CardContent className="flex w-full flex-col gap-4">
{!getIp && ( {!getIp && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You need to set an IP address in your{" "} You need to set an IP address in your{" "}
<Link <Link
href="/dashboard/settings/server" href="/dashboard/settings/server"
className="text-primary" className="text-primary"
> >
{data?.serverId {data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address" ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"} : "Web Server -> Server -> Update Server IP"}
</Link>{" "} </Link>{" "}
to fix the database url connection. to fix the database url connection.
</AlertBlock> </AlertBlock>
)} )}
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid md:grid-cols-2 gap-4 "> <div className="grid md:grid-cols-2 gap-4 ">
<div className="md:col-span-2 space-y-4"> <div className="md:col-span-2 space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="externalPort" name="externalPort"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>External Port (Internet)</FormLabel> <FormLabel>External Port (Internet)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="3306" placeholder="3306"
{...field} {...field}
value={field.value || ""} value={field.value || ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</div> </div>
</div> </div>
{!!data?.externalPort && ( {!!data?.externalPort && (
<div className="grid w-full gap-8"> <div className="grid w-full gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */} {/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */}
<Label>External Host</Label> <Label>External Host</Label>
<ToggleVisibilityInput value={connectionUrl} disabled /> <ToggleVisibilityInput value={connectionUrl} disabled />
</div> </div>
</div> </div>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" isLoading={isLoading}> <Button type="submit" isLoading={isLoading}>
Save Save
</Button> </Button>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };

View File

@@ -27,144 +27,148 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const DockerProviderSchema = z.object({ const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => { externalPort: z.preprocess((a) => {
if (a !== null) { if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10); const parsed = Number.parseInt(z.string().parse(a), 10);
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>;
interface Props { interface Props {
mongoId: string; mongoId: string;
} }
export const ShowExternalMongoCredentials = ({ mongoId }: Props) => { export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mongo.one.useQuery({ mongoId }); const { data, refetch } = api.mongo.one.useQuery({ mongoId });
const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip; const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
}); });
useEffect(() => { useEffect(() => {
if (data?.externalPort) { if (data?.externalPort) {
form.reset({ form.reset({
externalPort: data.externalPort, externalPort: data.externalPort,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => { const onSubmit = async (values: DockerProvider) => {
await mutateAsync({ await mutateAsync({
externalPort: values.externalPort, externalPort: values.externalPort,
mongoId, mongoId,
}) })
.then(async () => { .then(async () => {
toast.success("External Port updated"); toast.success("External Port updated");
await refetch(); await refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error saving the external port"); toast.error("Error saving the external port");
}); });
}; };
useEffect(() => { useEffect(() => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`; return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
}, [ }, [
data?.appName, data?.appName,
data?.externalPort, data?.externalPort,
data?.databasePassword, data?.databasePassword,
form, form,
data?.databaseUser, data?.databaseUser,
getIp, getIp,
]); ]);
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle> <CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription> <CardDescription>
In order to make the database reachable trought internet is In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another required to set a port, make sure the port is not used by another
application or database application or database
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-col gap-4"> <CardContent className="flex w-full flex-col gap-4">
{!getIp && ( {!getIp && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You need to set an IP address in your{" "} You need to set an IP address in your{" "}
<Link <Link
href="/dashboard/settings/server" href="/dashboard/settings/server"
className="text-primary" className="text-primary"
> >
{data?.serverId {data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address" ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"} : "Web Server -> Server -> Update Server IP"}
</Link>{" "} </Link>{" "}
to fix the database url connection. to fix the database url connection.
</AlertBlock> </AlertBlock>
)} )}
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid grid-cols-2 gap-4 "> <div className="grid grid-cols-2 gap-4 ">
<div className="col-span-2 space-y-4"> <div className="col-span-2 space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="externalPort" name="externalPort"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>External Port (Internet)</FormLabel> <FormLabel>External Port (Internet)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="27017" placeholder="27017"
{...field} {...field}
value={field.value || ""} value={field.value || ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</div> </div>
</div> </div>
{!!data?.externalPort && ( {!!data?.externalPort && (
<div className="grid w-full gap-8"> <div className="grid w-full gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>External Host</Label> <Label>External Host</Label>
<ToggleVisibilityInput value={connectionUrl} disabled /> <ToggleVisibilityInput value={connectionUrl} disabled />
</div> </div>
</div> </div>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" isLoading={isLoading}> <Button type="submit" isLoading={isLoading}>
Save Save
</Button> </Button>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };

View File

@@ -3,10 +3,10 @@ import { DrawerLogs } from "@/components/shared/drawer-logs";
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 { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import * as TooltipPrimitive from "@radix-ui/react-tooltip";
@@ -16,236 +16,246 @@ import { toast } from "sonner";
import { type LogLine, parseLogs } from "../../docker/logs/utils"; import { type LogLine, parseLogs } from "../../docker/logs/utils";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
interface Props { interface Props {
mongoId: string; mongoId: string;
} }
export const ShowGeneralMongo = ({ mongoId }: Props) => { export const ShowGeneralMongo = ({ mongoId }: Props) => {
const { data, refetch } = api.mongo.one.useQuery( const { data, refetch } = api.mongo.one.useQuery(
{ {
mongoId, mongoId,
}, },
{ enabled: !!mongoId } { enabled: !!mongoId },
); );
const { mutateAsync: reload, isLoading: isReloading } = const { mutateAsync: reload, isLoading: isReloading } =
api.mongo.reload.useMutation(); api.mongo.reload.useMutation();
const { mutateAsync: start, isLoading: isStarting } = const { mutateAsync: start, isLoading: isStarting } =
api.mongo.start.useMutation(); api.mongo.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } = const { mutateAsync: stop, isLoading: isStopping } =
api.mongo.stop.useMutation(); api.mongo.stop.useMutation();
const [isDrawerOpen, setIsDrawerOpen] = useState(false); const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false); const [isDeploying, setIsDeploying] = useState(false);
api.mongo.deployWithLogs.useSubscription( api.mongo.deployWithLogs.useSubscription(
{ {
mongoId: mongoId, mongoId: mongoId,
}, },
{ {
enabled: isDeploying, enabled: isDeploying,
onData(log) { onData(log) {
if (!isDrawerOpen) { if (!isDrawerOpen) {
setIsDrawerOpen(true); setIsDrawerOpen(true);
} }
if (log === "Deployment completed successfully!") { if (log === "Deployment completed successfully!") {
setIsDeploying(false); setIsDeploying(false);
} }
const parsedLogs = parseLogs(log); const parsedLogs = parseLogs(log);
setFilteredLogs((prev) => [...prev, ...parsedLogs]); setFilteredLogs((prev) => [...prev, ...parsedLogs]);
}, },
onError(error) { onError(error) {
console.error("Deployment logs error:", error); console.error("Deployment logs error:", error);
setIsDeploying(false); setIsDeploying(false);
}, },
} },
); );
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle> <CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap"> <CardContent className="flex flex-row gap-4 flex-wrap">
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<DialogAction <DialogAction
title="Deploy Mongo" title="Deploy Mongo"
description="Are you sure you want to deploy this mongo?" description="Are you sure you want to deploy this mongo?"
type="default" type="default"
onClick={async () => { onClick={async () => {
setIsDeploying(true); setIsDeploying(true);
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
refetch(); refetch();
}} }}
> >
<Tooltip> <Button
<TooltipTrigger asChild> variant="default"
<Button isLoading={data?.applicationStatus === "running"}
variant="default" className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
isLoading={data?.applicationStatus === "running"} >
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" <Tooltip>
> <TooltipTrigger asChild>
<Rocket className="size-4 mr-1" /> <div className="flex items-center">
Deploy <Rocket className="size-4 mr-1" />
</Button> Deploy
</TooltipTrigger> </div>
<TooltipPrimitive.Portal> </TooltipTrigger>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipPrimitive.Portal>
<p>Downloads and sets up the MongoDB database</p> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipContent> <p>Downloads and sets up the MongoDB database</p>
</TooltipPrimitive.Portal> </TooltipContent>
</Tooltip> </TooltipPrimitive.Portal>
</DialogAction> </Tooltip>
<DialogAction </Button>
title="Reload Mongo" </DialogAction>
description="Are you sure you want to reload this mongo?" <DialogAction
type="default" title="Reload Mongo"
onClick={async () => { description="Are you sure you want to reload this mongo?"
await reload({ type="default"
mongoId: mongoId, onClick={async () => {
appName: data?.appName || "", await reload({
}) mongoId: mongoId,
.then(() => { appName: data?.appName || "",
toast.success("Mongo reloaded successfully"); })
refetch(); .then(() => {
}) toast.success("Mongo reloaded successfully");
.catch(() => { refetch();
toast.error("Error reloading Mongo"); })
}); .catch(() => {
}} toast.error("Error reloading Mongo");
> });
<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"
> >
<RefreshCcw className="size-4 mr-1" /> <Tooltip>
Reload <TooltipTrigger asChild>
</Button> <div className="flex items-center">
</TooltipTrigger> <RefreshCcw className="size-4 mr-1" />
<TooltipPrimitive.Portal> Reload
<TooltipContent sideOffset={5} className="z-[60]"> </div>
<p>Restart the MongoDB service without rebuilding</p> </TooltipTrigger>
</TooltipContent> <TooltipPrimitive.Portal>
</TooltipPrimitive.Portal> <TooltipContent sideOffset={5} className="z-[60]">
</Tooltip> <p>Restart the MongoDB service without rebuilding</p>
</DialogAction> </TooltipContent>
{data?.applicationStatus === "idle" ? ( </TooltipPrimitive.Portal>
<DialogAction </Tooltip>
title="Start Mongo" </Button>
description="Are you sure you want to start this mongo?" </DialogAction>
type="default" {data?.applicationStatus === "idle" ? (
onClick={async () => { <DialogAction
await start({ title="Start Mongo"
mongoId: mongoId, description="Are you sure you want to start this mongo?"
}) type="default"
.then(() => { onClick={async () => {
toast.success("Mongo started successfully"); await start({
refetch(); mongoId: mongoId,
}) })
.catch(() => { .then(() => {
toast.error("Error starting Mongo"); toast.success("Mongo started successfully");
}); refetch();
}} })
> .catch(() => {
<Tooltip> toast.error("Error starting Mongo");
<TooltipTrigger asChild> });
<Button }}
variant="secondary" >
isLoading={isStarting} <Button
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" variant="secondary"
> isLoading={isStarting}
<CheckCircle2 className="size-4 mr-1" /> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
Start >
</Button> <Tooltip>
</TooltipTrigger> <TooltipTrigger asChild>
<TooltipPrimitive.Portal> <div className="flex items-center">
<TooltipContent sideOffset={5} className="z-[60]"> <CheckCircle2 className="size-4 mr-1" />
<p> Start
Start the MongoDB database (requires a previous </div>
successful setup) </TooltipTrigger>
</p> <TooltipPrimitive.Portal>
</TooltipContent> <TooltipContent sideOffset={5} className="z-[60]">
</TooltipPrimitive.Portal> <p>
</Tooltip> Start the MongoDB database (requires a previous
</DialogAction> successful setup)
) : ( </p>
<DialogAction </TooltipContent>
title="Stop Mongo" </TooltipPrimitive.Portal>
description="Are you sure you want to stop this mongo?" </Tooltip>
onClick={async () => { </Button>
await stop({ </DialogAction>
mongoId: mongoId, ) : (
}) <DialogAction
.then(() => { title="Stop Mongo"
toast.success("Mongo stopped successfully"); description="Are you sure you want to stop this mongo?"
refetch(); onClick={async () => {
}) await stop({
.catch(() => { mongoId: mongoId,
toast.error("Error stopping Mongo"); })
}); .then(() => {
}} toast.success("Mongo stopped successfully");
> refetch();
<Tooltip> })
<TooltipTrigger asChild> .catch(() => {
<Button toast.error("Error stopping Mongo");
variant="destructive" });
isLoading={isStopping} }}
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2" >
> <Button
<Ban className="size-4 mr-1" /> variant="destructive"
Stop isLoading={isStopping}
</Button> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
</TooltipTrigger> >
<TooltipPrimitive.Portal> <Tooltip>
<TooltipContent sideOffset={5} className="z-[60]"> <TooltipTrigger asChild>
<p>Stop the currently running MongoDB database</p> <div className="flex items-center">
</TooltipContent> <Ban className="size-4 mr-1" />
</TooltipPrimitive.Portal> Stop
</Tooltip> </div>
</DialogAction> </TooltipTrigger>
)} <TooltipPrimitive.Portal>
</TooltipProvider> <TooltipContent sideOffset={5} className="z-[60]">
<DockerTerminalModal <p>Stop the currently running MongoDB database</p>
appName={data?.appName || ""} </TooltipContent>
serverId={data?.serverId || ""} </TooltipPrimitive.Portal>
> </Tooltip>
<Tooltip> </Button>
<TooltipTrigger asChild> </DialogAction>
<Button )}
variant="outline" </TooltipProvider>
className="flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2" <DockerTerminalModal
> appName={data?.appName || ""}
<Terminal className="size-4" /> serverId={data?.serverId || ""}
Open Terminal >
</Button> <Button
</TooltipTrigger> variant="outline"
<TooltipPrimitive.Portal> className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
<TooltipContent sideOffset={5} className="z-[60]"> >
<p>Open a terminal to the MongoDB container</p> <Tooltip>
</TooltipContent> <TooltipTrigger asChild>
</TooltipPrimitive.Portal> <div className="flex items-center">
</Tooltip> <Terminal className="size-4 mr-1" />
</DockerTerminalModal> Open Terminal
</CardContent> </div>
</Card> </TooltipTrigger>
<DrawerLogs <TooltipPrimitive.Portal>
isOpen={isDrawerOpen} <TooltipContent sideOffset={5} className="z-[60]">
onClose={() => { <p>Open a terminal to the MongoDB container</p>
setIsDrawerOpen(false); </TooltipContent>
setFilteredLogs([]); </TooltipPrimitive.Portal>
setIsDeploying(false); </Tooltip>
refetch(); </Button>
}} </DockerTerminalModal>
filteredLogs={filteredLogs} </CardContent>
/> </Card>
</div> <DrawerLogs
</> isOpen={isDrawerOpen}
); onClose={() => {
setIsDrawerOpen(false);
setFilteredLogs([]);
setIsDeploying(false);
refetch();
}}
filteredLogs={filteredLogs}
/>
</div>
</>
);
}; };

View File

@@ -27,144 +27,148 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const DockerProviderSchema = z.object({ const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => { externalPort: z.preprocess((a) => {
if (a !== null) { if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10); const parsed = Number.parseInt(z.string().parse(a), 10);
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>;
interface Props { interface Props {
mysqlId: string; mysqlId: string;
} }
export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => { export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.mysql.one.useQuery({ mysqlId }); const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip; const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
}); });
useEffect(() => { useEffect(() => {
if (data?.externalPort) { if (data?.externalPort) {
form.reset({ form.reset({
externalPort: data.externalPort, externalPort: data.externalPort,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => { const onSubmit = async (values: DockerProvider) => {
await mutateAsync({ await mutateAsync({
externalPort: values.externalPort, externalPort: values.externalPort,
mysqlId, mysqlId,
}) })
.then(async () => { .then(async () => {
toast.success("External Port updated"); toast.success("External Port updated");
await refetch(); await refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error saving the external port"); toast.error("Error saving the external port");
}); });
}; };
useEffect(() => { useEffect(() => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `mysql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`; return `mysql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
}, [ }, [
data?.appName, data?.appName,
data?.externalPort, data?.externalPort,
data?.databasePassword, data?.databasePassword,
data?.databaseName, data?.databaseName,
data?.databaseUser, data?.databaseUser,
form, form,
getIp, getIp,
]); ]);
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle> <CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription> <CardDescription>
In order to make the database reachable trought internet is In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another required to set a port, make sure the port is not used by another
application or database application or database
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-col gap-4"> <CardContent className="flex w-full flex-col gap-4">
{!getIp && ( {!getIp && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You need to set an IP address in your{" "} You need to set an IP address in your{" "}
<Link <Link
href="/dashboard/settings/server" href="/dashboard/settings/server"
className="text-primary" className="text-primary"
> >
{data?.serverId {data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address" ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"} : "Web Server -> Server -> Update Server IP"}
</Link>{" "} </Link>{" "}
to fix the database url connection. to fix the database url connection.
</AlertBlock> </AlertBlock>
)} )}
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid grid-cols-2 gap-4 "> <div className="grid grid-cols-2 gap-4 ">
<div className="col-span-2 space-y-4"> <div className="col-span-2 space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="externalPort" name="externalPort"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>External Port (Internet)</FormLabel> <FormLabel>External Port (Internet)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="3306" placeholder="3306"
{...field} {...field}
value={field.value || ""} value={field.value || ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</div> </div>
</div> </div>
{!!data?.externalPort && ( {!!data?.externalPort && (
<div className="grid w-full gap-8"> <div className="grid w-full gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>External Host</Label> <Label>External Host</Label>
<ToggleVisibilityInput disabled value={connectionUrl} /> <ToggleVisibilityInput disabled value={connectionUrl} />
</div> </div>
</div> </div>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" isLoading={isLoading}> <Button type="submit" isLoading={isLoading}>
Save Save
</Button> </Button>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };

View File

@@ -27,146 +27,150 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const DockerProviderSchema = z.object({ const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => { externalPort: z.preprocess((a) => {
if (a !== null) { if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10); const parsed = Number.parseInt(z.string().parse(a), 10);
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>;
interface Props { interface Props {
postgresId: string; postgresId: string;
} }
export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => { export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.postgres.one.useQuery({ postgresId }); const { data, refetch } = api.postgres.one.useQuery({ postgresId });
const { mutateAsync, isLoading } = const { mutateAsync, isLoading } =
api.postgres.saveExternalPort.useMutation(); api.postgres.saveExternalPort.useMutation();
const getIp = data?.server?.ipAddress || ip; const getIp = data?.server?.ipAddress || ip;
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
}); });
useEffect(() => { useEffect(() => {
if (data?.externalPort) { if (data?.externalPort) {
form.reset({ form.reset({
externalPort: data.externalPort, externalPort: data.externalPort,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => { const onSubmit = async (values: DockerProvider) => {
await mutateAsync({ await mutateAsync({
externalPort: values.externalPort, externalPort: values.externalPort,
postgresId, postgresId,
}) })
.then(async () => { .then(async () => {
toast.success("External Port updated"); toast.success("External Port updated");
await refetch(); await refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error saving the external port"); toast.error("Error saving the external port");
}); });
}; };
useEffect(() => { useEffect(() => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`; return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
}, [ }, [
data?.appName, data?.appName,
data?.externalPort, data?.externalPort,
data?.databasePassword, data?.databasePassword,
form, form,
data?.databaseName, data?.databaseName,
getIp, getIp,
]); ]);
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle> <CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription> <CardDescription>
In order to make the database reachable trought internet is In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another required to set a port, make sure the port is not used by another
application or database application or database
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-col gap-4"> <CardContent className="flex w-full flex-col gap-4">
{!getIp && ( {!getIp && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You need to set an IP address in your{" "} You need to set an IP address in your{" "}
<Link <Link
href="/dashboard/settings/server" href="/dashboard/settings/server"
className="text-primary" className="text-primary"
> >
{data?.serverId {data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address" ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"} : "Web Server -> Server -> Update Server IP"}
</Link>{" "} </Link>{" "}
to fix the database url connection. to fix the database url connection.
</AlertBlock> </AlertBlock>
)} )}
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid grid-cols-2 gap-4 "> <div className="grid grid-cols-2 gap-4 ">
<div className="col-span-2 space-y-4"> <div className="col-span-2 space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="externalPort" name="externalPort"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>External Port (Internet)</FormLabel> <FormLabel>External Port (Internet)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="5432" placeholder="5432"
{...field} {...field}
value={field.value || ""} value={field.value || ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</div> </div>
</div> </div>
{!!data?.externalPort && ( {!!data?.externalPort && (
<div className="grid w-full gap-8"> <div className="grid w-full gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>External Host</Label> <Label>External Host</Label>
<ToggleVisibilityInput value={connectionUrl} disabled /> <ToggleVisibilityInput value={connectionUrl} disabled />
</div> </div>
</div> </div>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" isLoading={isLoading}> <Button type="submit" isLoading={isLoading}>
Save Save
</Button> </Button>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };

View File

@@ -5,58 +5,58 @@ import { Label } from "@/components/ui/label";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
interface Props { interface Props {
postgresId: string; postgresId: string;
} }
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => { export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
const { data } = api.postgres.one.useQuery({ postgresId }); const { data } = api.postgres.one.useQuery({ postgresId });
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Internal Credentials</CardTitle> <CardTitle className="text-xl">Internal Credentials</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-row gap-4"> <CardContent className="flex w-full flex-row gap-4">
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8"> <div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>User</Label> <Label>User</Label>
<Input disabled value={data?.databaseUser} /> <Input disabled value={data?.databaseUser} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Database Name</Label> <Label>Database Name</Label>
<Input disabled value={data?.databaseName} /> <Input disabled value={data?.databaseName} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Password</Label> <Label>Password</Label>
<div className="flex flex-row gap-4"> <div className="flex flex-row gap-4">
<ToggleVisibilityInput <ToggleVisibilityInput
value={data?.databasePassword} value={data?.databasePassword}
disabled disabled
/> />
</div> </div>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Port (Container)</Label> <Label>Internal Port (Container)</Label>
<Input disabled value="5432" /> <Input disabled value="5432" />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Host</Label> <Label>Internal Host</Label>
<Input disabled value={data?.appName} /> <Input disabled value={data?.appName} />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Internal Connection URL </Label> <Label>Internal Connection URL </Label>
<ToggleVisibilityInput <ToggleVisibilityInput
disabled disabled
value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`} value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`}
/> />
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };
// ReplyError: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-w // ReplyError: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-w

View File

@@ -28,139 +28,139 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const updatePostgresSchema = z.object({ const updatePostgresSchema = z.object({
name: z.string().min(1, { name: z.string().min(1, {
message: "Name is required", message: "Name is required",
}), }),
description: z.string().optional(), description: z.string().optional(),
}); });
type UpdatePostgres = z.infer<typeof updatePostgresSchema>; type UpdatePostgres = z.infer<typeof updatePostgresSchema>;
interface Props { interface Props {
postgresId: string; postgresId: string;
} }
export const UpdatePostgres = ({ postgresId }: Props) => { export const UpdatePostgres = ({ postgresId }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } = const { mutateAsync, error, isError, isLoading } =
api.postgres.update.useMutation(); api.postgres.update.useMutation();
const { data } = api.postgres.one.useQuery( const { data } = api.postgres.one.useQuery(
{ {
postgresId, postgresId,
}, },
{ {
enabled: !!postgresId, enabled: !!postgresId,
} },
); );
const form = useForm<UpdatePostgres>({ const form = useForm<UpdatePostgres>({
defaultValues: { defaultValues: {
description: data?.description ?? "", description: data?.description ?? "",
name: data?.name ?? "", name: data?.name ?? "",
}, },
resolver: zodResolver(updatePostgresSchema), resolver: zodResolver(updatePostgresSchema),
}); });
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset({
description: data.description ?? "", description: data.description ?? "",
name: data.name, name: data.name,
}); });
} }
}, [data, form, form.reset]); }, [data, form, form.reset]);
const onSubmit = async (formData: UpdatePostgres) => { const onSubmit = async (formData: UpdatePostgres) => {
await mutateAsync({ await mutateAsync({
name: formData.name, name: formData.name,
postgresId: postgresId, postgresId: postgresId,
description: formData.description || "", description: formData.description || "",
}) })
.then(() => { .then(() => {
toast.success("Postgres updated successfully"); toast.success("Postgres updated successfully");
utils.postgres.one.invalidate({ utils.postgres.one.invalidate({
postgresId: postgresId, postgresId: postgresId,
}); });
setIsOpen(false); setIsOpen(false);
}) })
.catch(() => { .catch(() => {
toast.error("Error updating Postgres"); toast.error("Error updating Postgres");
}) })
.finally(() => {}); .finally(() => {});
}; };
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-blue-500/10 focus-visible:ring-2 focus-visible:ring-offset-2" className="group hover:bg-blue-500/10 focus-visible:ring-2 focus-visible:ring-offset-2"
> >
<PenBox className="size-3.5 text-primary group-hover:text-blue-500" /> <PenBox className="size-3.5 text-primary group-hover:text-blue-500" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Modify Postgres</DialogTitle> <DialogTitle>Modify Postgres</DialogTitle>
<DialogDescription>Update the Postgres data</DialogDescription> <DialogDescription>Update the Postgres data</DialogDescription>
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid items-center gap-4"> <div className="grid items-center gap-4">
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-update-postgres" id="hook-form-update-postgres"
className="grid w-full gap-4 " className="grid w-full gap-4 "
> >
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Vandelay Industries" {...field} /> <Input placeholder="Vandelay Industries" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="description" name="description"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="Description about your project..." placeholder="Description about your project..."
className="resize-none" className="resize-none"
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isLoading}
form="hook-form-update-postgres" form="hook-form-update-postgres"
type="submit" type="submit"
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"
> >
Update Update
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };

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">
<Link {template?.links?.github && (
href={template.links.github}
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<GithubIcon className="size-5" />
</Link>
{template.links.website && (
<Link <Link
href={template.links.website} href={template?.links?.github}
target="_blank"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<GithubIcon className="size-5" />
</Link>
)}
{template?.links?.website && (
<Link
href={template?.links?.website}
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,9 +31,14 @@ 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
message: "Name is required", .string()
}), .min(1, {
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

@@ -27,138 +27,142 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const DockerProviderSchema = z.object({ const DockerProviderSchema = z.object({
externalPort: z.preprocess((a) => { externalPort: z.preprocess((a) => {
if (a !== null) { if (a !== null) {
const parsed = Number.parseInt(z.string().parse(a), 10); const parsed = Number.parseInt(z.string().parse(a), 10);
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>;
interface Props { interface Props {
redisId: string; redisId: string;
} }
export const ShowExternalRedisCredentials = ({ redisId }: Props) => { export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
const { data: ip } = api.settings.getIp.useQuery(); const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.redis.one.useQuery({ redisId }); const { data, refetch } = api.redis.one.useQuery({ redisId });
const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation(); const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
const [connectionUrl, setConnectionUrl] = useState(""); const [connectionUrl, setConnectionUrl] = useState("");
const getIp = data?.server?.ipAddress || ip; const getIp = data?.server?.ipAddress || ip;
const form = useForm<DockerProvider>({ const form = useForm<DockerProvider>({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(DockerProviderSchema), resolver: zodResolver(DockerProviderSchema),
}); });
useEffect(() => { useEffect(() => {
if (data?.externalPort) { if (data?.externalPort) {
form.reset({ form.reset({
externalPort: data.externalPort, externalPort: data.externalPort,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => { const onSubmit = async (values: DockerProvider) => {
await mutateAsync({ await mutateAsync({
externalPort: values.externalPort, externalPort: values.externalPort,
redisId, redisId,
}) })
.then(async () => { .then(async () => {
toast.success("External Port updated"); toast.success("External Port updated");
await refetch(); await refetch();
}) })
.catch(() => { .catch(() => {
toast.error("Error saving the external port"); toast.error("Error saving the external port");
}); });
}; };
useEffect(() => { useEffect(() => {
const buildConnectionUrl = () => { const buildConnectionUrl = () => {
const _hostname = window.location.hostname; const _hostname = window.location.hostname;
const port = form.watch("externalPort") || data?.externalPort; const port = form.watch("externalPort") || data?.externalPort;
return `redis://default:${data?.databasePassword}@${getIp}:${port}`; return `redis://default:${data?.databasePassword}@${getIp}:${port}`;
}; };
setConnectionUrl(buildConnectionUrl()); setConnectionUrl(buildConnectionUrl());
}, [data?.appName, data?.externalPort, data?.databasePassword, form, getIp]); }, [data?.appName, data?.externalPort, data?.databasePassword, form, getIp]);
return ( return (
<> <>
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle> <CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription> <CardDescription>
In order to make the database reachable trought internet is In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another required to set a port, make sure the port is not used by another
application or database application or database
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-col gap-4"> <CardContent className="flex w-full flex-col gap-4">
{!getIp && ( {!getIp && (
<AlertBlock type="warning"> <AlertBlock type="warning">
You need to set an IP address in your{" "} You need to set an IP address in your{" "}
<Link <Link
href="/dashboard/settings/server" href="/dashboard/settings/server"
className="text-primary" className="text-primary"
> >
{data?.serverId {data?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address" ? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"} : "Web Server -> Server -> Update Server IP"}
</Link>{" "} </Link>{" "}
to fix the database url connection. to fix the database url connection.
</AlertBlock> </AlertBlock>
)} )}
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
<div className="grid grid-cols-2 gap-4 "> <div className="grid grid-cols-2 gap-4 ">
<div className="col-span-2 space-y-4"> <div className="col-span-2 space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="externalPort" name="externalPort"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel>External Port (Internet)</FormLabel> <FormLabel>External Port (Internet)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="6379" placeholder="6379"
{...field} {...field}
value={field.value || ""} value={field.value || ""}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
); );
}} }}
/> />
</div> </div>
</div> </div>
{!!data?.externalPort && ( {!!data?.externalPort && (
<div className="grid w-full gap-8"> <div className="grid w-full gap-8">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Label>External Host</Label> <Label>External Host</Label>
<ToggleVisibilityInput value={connectionUrl} disabled /> <ToggleVisibilityInput value={connectionUrl} disabled />
</div> </div>
</div> </div>
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" isLoading={isLoading}> <Button type="submit" isLoading={isLoading}>
Save Save
</Button> </Button>
</div> </div>
</form> </form>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</> </>
); );
}; };

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

@@ -55,7 +55,7 @@ export const AiForm = () => {
key={config.aiId} key={config.aiId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div> <div>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{config.name} {config.name}

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

@@ -70,7 +70,7 @@ export const ShowCertificates = () => {
key={certificate.certificateId} key={certificate.certificateId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-2 flex-col"> <div className="flex gap-2 flex-col">
<span className="text-sm font-medium"> <span className="text-sm font-medium">

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,56 +15,66 @@ 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>}
<div className="flex flex-col gap-2.5 text-sm"> {isLoading ? (
<span>1. Go to your new server and run the following command</span> <Loader2 className="w-full animate-spin text-muted-foreground" />
<span className="bg-muted rounded-lg p-2 flex justify-between"> ) : (
curl https://get.docker.com | sh -s -- --version {data?.version} <>
<button <div className="flex flex-col gap-2.5 text-sm">
type="button" <span>
className="self-center" 1. Go to your new server and run the following command
onClick={() => { </span>
copy( <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
toast.success("Copied to clipboard"); type="button"
}} className="self-center"
> onClick={() => {
<CopyIcon className="h-4 w-4 cursor-pointer" /> copy(
</button> `curl https://get.docker.com | sh -s -- --version ${data?.version}`,
</span> );
</div> toast.success("Copied to clipboard");
}}
>
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
<div className="flex flex-col gap-2.5 text-sm"> <div className="flex flex-col gap-2.5 text-sm">
<span> <span>
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">
{data?.command} <span className="bg-muted rounded-lg p-2 flex">
<button {data?.command}
type="button" <button
className="self-start" type="button"
onClick={() => { className="self-start"
copy(data?.command || ""); onClick={() => {
toast.success("Copied to clipboard"); copy(data?.command || "");
}} toast.success("Copied to clipboard");
> }}
<CopyIcon className="h-4 w-4 cursor-pointer" /> >
</button> <CopyIcon className="h-4 w-4 cursor-pointer" />
</span> </button>
</div> </span>
</CardContent> </div>
</div> </>
)}
</CardContent>
</> </>
); );
}; };

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,54 +15,62 @@ 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>}
<div className="flex flex-col gap-2.5 text-sm"> {isLoading ? (
<span>1. Go to your new server and run the following command</span> <Loader2 className="w-full animate-spin text-muted-foreground" />
<span className="bg-muted rounded-lg p-2 flex justify-between"> ) : (
curl https://get.docker.com | sh -s -- --version {data?.version} <>
<button <div className="flex flex-col gap-2.5 text-sm">
type="button" <span>1. Go to your new server and run the following command</span>
className="self-center" <span className="bg-muted rounded-lg p-2 flex justify-between">
onClick={() => { curl https://get.docker.com | sh -s -- --version {data?.version}
copy( <button
`curl https://get.docker.com | sh -s -- --version ${data?.version}`, type="button"
); className="self-center"
toast.success("Copied to clipboard"); onClick={() => {
}} copy(
> `curl https://get.docker.com | sh -s -- --version ${data?.version}`,
<CopyIcon className="h-4 w-4 cursor-pointer" /> );
</button> toast.success("Copied to clipboard");
</span> }}
</div> >
<CopyIcon className="h-4 w-4 cursor-pointer" />
</button>
</span>
</div>
<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
</span> cluster
</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
type="button" type="button"
className="self-start" className="self-start"
onClick={() => { onClick={() => {
copy(data?.command || ""); copy(data?.command || "");
toast.success("Copied to clipboard"); toast.success("Copied to clipboard");
}} }}
> >
<CopyIcon className="h-4 w-4 cursor-pointer" /> <CopyIcon className="h-4 w-4 cursor-pointer" />
</button> </button>
</span> </span>
</div> </div>
</CardContent> </>
</div> )}
</CardContent>
); );
}; };

View File

@@ -54,7 +54,7 @@ export const ShowRegistry = () => {
key={registry.registryId} key={registry.registryId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-2 flex-col"> <div className="flex gap-2 flex-col">
<span className="text-sm font-medium"> <span className="text-sm font-medium">

View File

@@ -55,7 +55,7 @@ export const ShowDestinations = () => {
key={destination.destinationId} key={destination.destinationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-sm"> <span className="text-sm">
{index + 1}. {destination.name} {index + 1}. {destination.name}

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;
@@ -122,18 +129,19 @@ export const ShowGitProviders = () => {
key={gitProvider.gitProviderId} key={gitProvider.gitProviderId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col items-center justify-between"> <div className="flex 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?"
@@ -238,7 +253,7 @@ export const ShowGitProviders = () => {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-red-500/10 " className="group hover:bg-red-500/10"
isLoading={isRemoving} isLoading={isRemoving}
> >
<Trash2 className="size-4 text-primary group-hover:text-red-500" /> <Trash2 className="size-4 text-primary group-hover:text-red-500" />

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

@@ -61,7 +61,7 @@ export const ShowNotifications = () => {
key={notification.notificationId} key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm flex flex-row items-center gap-4"> <span className="text-sm flex flex-row items-center gap-4">
{notification.notificationType === "slack" && ( {notification.notificationType === "slack" && (
<div className="flex items-center justify-center rounded-lg"> <div className="flex items-center justify-center rounded-lg">

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,36 +307,33 @@ export const Enable2FA = () => {
)} )}
</div> </div>
<FormField <div className="flex flex-col justify-center items-center">
control={pinForm.control} <FormLabel>Verification Code</FormLabel>
name="pin" <InputOTP
render={({ field }) => ( maxLength={6}
<FormItem className="flex flex-col justify-center items-center"> value={otpValue}
<FormLabel>Verification Code</FormLabel> onChange={setOtpValue}
<FormControl> autoComplete="off"
<InputOTP maxLength={6} {...field}> >
<InputOTPGroup> <InputOTPGroup>
<InputOTPSlot index={0} /> <InputOTPSlot index={0} />
<InputOTPSlot index={1} /> <InputOTPSlot index={1} />
<InputOTPSlot index={2} /> <InputOTPSlot index={2} />
<InputOTPSlot index={3} /> <InputOTPSlot index={3} />
<InputOTPSlot index={4} /> <InputOTPSlot index={4} />
<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> </div>
<FormMessage />
</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 || "", {
password: "", email: data?.user?.email || "",
image: data?.user?.image || "", password: form.getValues("password") || "",
currentPassword: "", image: data?.user?.image || "",
}); 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

@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
key={sshKey.sshKeyId} key={sshKey.sshKeyId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium"> <span className="text-sm font-medium">

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();
@@ -155,44 +169,67 @@ export const WebDomain = () => {
/> />
<FormField <FormField
control={form.control} control={form.control}
name="certificateType" name="https"
render={({ field }) => { render={({ field }) => (
return ( <FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm w-full col-span-2">
<FormItem className="md:col-span-2"> <div className="space-y-0.5">
<FormLabel> <FormLabel>HTTPS</FormLabel>
{t("settings.server.domain.form.certificate.label")} <FormDescription>
</FormLabel> Automatically provision SSL Certificate.
<Select </FormDescription>
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage /> <FormMessage />
</FormItem> </div>
); <FormControl>
}} <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/> />
{https && (
<FormField
control={form.control}
name="certificateType"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
)}
<div className="flex w-full justify-end col-span-2"> <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

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