mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge branch 'canary' into fix-mount-update
This commit is contained in:
2
.github/workflows/dokploy.yml
vendored
2
.github/workflows/dokploy.yml
vendored
@@ -2,7 +2,7 @@ name: Dokploy Docker Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, canary, "feat/better-auth-2"]
|
||||
branches: [main, canary, "1061-custom-docker-service-hostname"]
|
||||
|
||||
env:
|
||||
IMAGE_NAME: dokploy/dokploy
|
||||
|
||||
22
.github/workflows/format.yml
vendored
Normal file
22
.github/workflows/format.yml
vendored
Normal 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
|
||||
@@ -52,7 +52,7 @@ feat: add new feature
|
||||
|
||||
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
||||
|
||||
We use Node v20.9.0
|
||||
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
|
||||
git clone https://github.com/dokploy/dokploy.git
|
||||
@@ -61,9 +61,9 @@ pnpm install
|
||||
cp apps/dokploy/.env.example apps/dokploy/.env
|
||||
```
|
||||
|
||||
## Development
|
||||
## Requirements
|
||||
|
||||
Is required to have **Docker** installed on your machine.
|
||||
- [Docker](/GUIDES.md#docker)
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -87,6 +87,8 @@ pnpm run dokploy:dev
|
||||
|
||||
Go to http://localhost:3000 to see the development server
|
||||
|
||||
Note: this project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
@@ -145,11 +147,9 @@ curl -sSL https://railpack.com/install.sh | sh
|
||||
|
||||
```bash
|
||||
# Install Buildpacks
|
||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||
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
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
### Recommendations
|
||||
|
||||
- Use the same name of the folder as the id of the template.
|
||||
|
||||
@@ -29,7 +29,7 @@ WORKDIR /app
|
||||
# Set 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 --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
|
||||
# | 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 \
|
||||
&& chmod +x install.sh \
|
||||
&& ./install.sh \
|
||||
|
||||
49
GUIDES.md
Normal file
49
GUIDES.md
Normal 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
|
||||
@@ -1006,7 +1006,7 @@ services:
|
||||
|
||||
volumes:
|
||||
db-config-testhash:
|
||||
`) as ComposeSpecification;
|
||||
`);
|
||||
|
||||
test("Expect to change the suffix in all the possible places (4 Try)", () => {
|
||||
const composeData = load(composeFileComplex) as ComposeSpecification;
|
||||
@@ -1115,3 +1115,60 @@ test("Expect to change the suffix in all the possible places (5 Try)", () => {
|
||||
|
||||
expect(updatedComposeData).toEqual(expectedDockerComposeExample1);
|
||||
});
|
||||
|
||||
const composeFileBackrest = `
|
||||
services:
|
||||
backrest:
|
||||
image: garethgeorge/backrest:v1.7.3
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 9898
|
||||
environment:
|
||||
- BACKREST_PORT=9898
|
||||
- BACKREST_DATA=/data
|
||||
- BACKREST_CONFIG=/config/config.json
|
||||
- XDG_CACHE_HOME=/cache
|
||||
- TZ=\${TZ}
|
||||
volumes:
|
||||
- backrest/data:/data
|
||||
- backrest/config:/config
|
||||
- backrest/cache:/cache
|
||||
- /:/userdata:ro
|
||||
|
||||
volumes:
|
||||
backrest:
|
||||
backrest-cache:
|
||||
`;
|
||||
|
||||
const expectedDockerComposeBackrest = load(`
|
||||
services:
|
||||
backrest:
|
||||
image: garethgeorge/backrest:v1.7.3
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 9898
|
||||
environment:
|
||||
- BACKREST_PORT=9898
|
||||
- BACKREST_DATA=/data
|
||||
- BACKREST_CONFIG=/config/config.json
|
||||
- XDG_CACHE_HOME=/cache
|
||||
- TZ=\${TZ}
|
||||
volumes:
|
||||
- backrest-testhash/data:/data
|
||||
- backrest-testhash/config:/config
|
||||
- backrest-testhash/cache:/cache
|
||||
- /:/userdata:ro
|
||||
|
||||
volumes:
|
||||
backrest-testhash:
|
||||
backrest-cache-testhash:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Should handle volume paths with subdirectories correctly", () => {
|
||||
const composeData = load(composeFileBackrest) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||
|
||||
expect(updatedComposeData).toEqual(expectedDockerComposeBackrest);
|
||||
});
|
||||
|
||||
@@ -27,6 +27,11 @@ if (typeof window === "undefined") {
|
||||
const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
herokuVersion: "",
|
||||
giteaBranch: "",
|
||||
giteaBuildPath: "",
|
||||
giteaId: "",
|
||||
giteaOwner: "",
|
||||
giteaRepository: "",
|
||||
cleanCache: false,
|
||||
watchPaths: [],
|
||||
applicationStatus: "done",
|
||||
|
||||
@@ -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 { processTemplate } from "@dokploy/server/templates/processors";
|
||||
import type { Schema } from "@dokploy/server/templates";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("processTemplate", () => {
|
||||
// Mock schema for testing
|
||||
@@ -51,6 +51,35 @@ describe("processTemplate", () => {
|
||||
expect(result.domains).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", () => {
|
||||
@@ -233,6 +262,49 @@ describe("processTemplate", () => {
|
||||
expect(base64Value.length).toBeGreaterThanOrEqual(42);
|
||||
expect(base64Value.length).toBeLessThanOrEqual(44);
|
||||
});
|
||||
|
||||
it("should handle boolean values in env vars when provided as an array", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
domains: [],
|
||||
env: [
|
||||
"ENABLE_USER_SIGN_UP=false",
|
||||
"DEBUG_MODE=true",
|
||||
"SOME_NUMBER=42",
|
||||
],
|
||||
mounts: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.envs).toHaveLength(3);
|
||||
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
|
||||
expect(result.envs).toContain("DEBUG_MODE=true");
|
||||
expect(result.envs).toContain("SOME_NUMBER=42");
|
||||
});
|
||||
|
||||
it("should handle boolean values in env vars when provided as an object", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
domains: [],
|
||||
env: {
|
||||
ENABLE_USER_SIGN_UP: false,
|
||||
DEBUG_MODE: true,
|
||||
SOME_NUMBER: 42,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.envs).toHaveLength(3);
|
||||
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
|
||||
expect(result.envs).toContain("DEBUG_MODE=true");
|
||||
expect(result.envs).toContain("SOME_NUMBER=42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mounts processing", () => {
|
||||
|
||||
232
apps/dokploy/__test__/templates/helpers.template.test.ts
Normal file
232
apps/dokploy/__test__/templates/helpers.template.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const baseAdmin: User = {
|
||||
https: false,
|
||||
enablePaidFeatures: false,
|
||||
metricsConfig: {
|
||||
containers: {
|
||||
@@ -73,7 +74,6 @@ beforeEach(() => {
|
||||
|
||||
test("Should read the configuration file", () => {
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
|
||||
"dokploy-service-app",
|
||||
);
|
||||
@@ -83,6 +83,7 @@ test("Should apply redirect-to-https", () => {
|
||||
updateServerTraefik(
|
||||
{
|
||||
...baseAdmin,
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"example.com",
|
||||
|
||||
@@ -7,6 +7,11 @@ import { expect, test } from "vitest";
|
||||
const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
herokuVersion: "",
|
||||
giteaRepository: "",
|
||||
giteaOwner: "",
|
||||
giteaBranch: "",
|
||||
giteaBuildPath: "",
|
||||
giteaId: "",
|
||||
cleanCache: false,
|
||||
applicationStatus: "done",
|
||||
appName: "",
|
||||
|
||||
61
apps/dokploy/__test__/utils/backups.test.ts
Normal file
61
apps/dokploy/__test__/utils/backups.test.ts
Normal 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/");
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const AddRedirectchema = z.object({
|
||||
replicas: z.number(),
|
||||
replicas: z.number().min(1, "Replicas must be at least 1"),
|
||||
registryId: z.string(),
|
||||
});
|
||||
|
||||
@@ -130,9 +130,11 @@ export const ShowClusterSettings = ({ applicationId }: Props) => {
|
||||
placeholder="1"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(Number(e.target.value));
|
||||
const value = e.target.value;
|
||||
field.onChange(value === "" ? 0 : Number(value));
|
||||
}}
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -32,7 +33,6 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
|
||||
const ImportSchema = z.object({
|
||||
base64: z.string(),
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
enum BuildType {
|
||||
export enum BuildType {
|
||||
dockerfile = "dockerfile",
|
||||
heroku_buildpacks = "heroku_buildpacks",
|
||||
paketo_buildpacks = "paketo_buildpacks",
|
||||
@@ -29,9 +29,18 @@ enum BuildType {
|
||||
railpack = "railpack",
|
||||
}
|
||||
|
||||
const buildTypeDisplayMap: Record<BuildType, string> = {
|
||||
[BuildType.dockerfile]: "Dockerfile",
|
||||
[BuildType.railpack]: "Railpack",
|
||||
[BuildType.nixpacks]: "Nixpacks",
|
||||
[BuildType.heroku_buildpacks]: "Heroku Buildpacks",
|
||||
[BuildType.paketo_buildpacks]: "Paketo Buildpacks",
|
||||
[BuildType.static]: "Static",
|
||||
};
|
||||
|
||||
const mySchema = z.discriminatedUnion("buildType", [
|
||||
z.object({
|
||||
buildType: z.literal("dockerfile"),
|
||||
buildType: z.literal(BuildType.dockerfile),
|
||||
dockerfile: z
|
||||
.string({
|
||||
required_error: "Dockerfile path is required",
|
||||
@@ -42,39 +51,88 @@ const mySchema = z.discriminatedUnion("buildType", [
|
||||
dockerBuildStage: z.string().nullable().default(""),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("heroku_buildpacks"),
|
||||
buildType: z.literal(BuildType.heroku_buildpacks),
|
||||
herokuVersion: z.string().nullable().default(""),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("paketo_buildpacks"),
|
||||
buildType: z.literal(BuildType.paketo_buildpacks),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("nixpacks"),
|
||||
buildType: z.literal(BuildType.nixpacks),
|
||||
publishDirectory: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("static"),
|
||||
buildType: z.literal(BuildType.static),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("railpack"),
|
||||
buildType: z.literal(BuildType.railpack),
|
||||
}),
|
||||
]);
|
||||
|
||||
type AddTemplate = z.infer<typeof mySchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
interface ApplicationData {
|
||||
buildType: BuildType;
|
||||
dockerfile?: string | null;
|
||||
dockerContextPath?: string | null;
|
||||
dockerBuildStage?: string | null;
|
||||
herokuVersion?: string | null;
|
||||
publishDirectory?: string | null;
|
||||
}
|
||||
|
||||
function isValidBuildType(value: string): value is BuildType {
|
||||
return Object.values(BuildType).includes(value as BuildType);
|
||||
}
|
||||
|
||||
const resetData = (data: ApplicationData): AddTemplate => {
|
||||
switch (data.buildType) {
|
||||
case BuildType.dockerfile:
|
||||
return {
|
||||
buildType: BuildType.dockerfile,
|
||||
dockerfile: data.dockerfile || "",
|
||||
dockerContextPath: data.dockerContextPath || "",
|
||||
dockerBuildStage: data.dockerBuildStage || "",
|
||||
};
|
||||
case BuildType.heroku_buildpacks:
|
||||
return {
|
||||
buildType: BuildType.heroku_buildpacks,
|
||||
herokuVersion: data.herokuVersion || "",
|
||||
};
|
||||
case BuildType.nixpacks:
|
||||
return {
|
||||
buildType: BuildType.nixpacks,
|
||||
publishDirectory: data.publishDirectory || undefined,
|
||||
};
|
||||
case BuildType.paketo_buildpacks:
|
||||
return {
|
||||
buildType: BuildType.paketo_buildpacks,
|
||||
};
|
||||
case BuildType.static:
|
||||
return {
|
||||
buildType: BuildType.static,
|
||||
};
|
||||
case BuildType.railpack:
|
||||
return {
|
||||
buildType: BuildType.railpack,
|
||||
};
|
||||
default:
|
||||
const buildType = data.buildType as BuildType;
|
||||
return {
|
||||
buildType,
|
||||
} as AddTemplate;
|
||||
}
|
||||
};
|
||||
|
||||
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
api.application.saveBuildType.useMutation();
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
{ applicationId },
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const form = useForm<AddTemplate>({
|
||||
@@ -85,46 +143,36 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
});
|
||||
|
||||
const buildType = form.watch("buildType");
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
if (data.buildType === "dockerfile") {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
...(data.buildType && {
|
||||
dockerfile: data.dockerfile || "",
|
||||
dockerContextPath: data.dockerContextPath || "",
|
||||
dockerBuildStage: data.dockerBuildStage || "",
|
||||
}),
|
||||
});
|
||||
} else if (data.buildType === "heroku_buildpacks") {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
...(data.buildType && {
|
||||
herokuVersion: data.herokuVersion || "",
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
publishDirectory: data.publishDirectory || undefined,
|
||||
});
|
||||
}
|
||||
const typedData: ApplicationData = {
|
||||
...data,
|
||||
buildType: isValidBuildType(data.buildType)
|
||||
? (data.buildType as BuildType)
|
||||
: BuildType.nixpacks, // fallback
|
||||
};
|
||||
|
||||
form.reset(resetData(typedData));
|
||||
}
|
||||
}, [form.formState.isSubmitSuccessful, form.reset, data, form]);
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (data: AddTemplate) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
buildType: data.buildType,
|
||||
publishDirectory:
|
||||
data.buildType === "nixpacks" ? data.publishDirectory : null,
|
||||
dockerfile: data.buildType === "dockerfile" ? data.dockerfile : null,
|
||||
data.buildType === BuildType.nixpacks ? data.publishDirectory : null,
|
||||
dockerfile:
|
||||
data.buildType === BuildType.dockerfile ? data.dockerfile : null,
|
||||
dockerContextPath:
|
||||
data.buildType === "dockerfile" ? data.dockerContextPath : null,
|
||||
data.buildType === BuildType.dockerfile ? data.dockerContextPath : null,
|
||||
dockerBuildStage:
|
||||
data.buildType === "dockerfile" ? data.dockerBuildStage : null,
|
||||
data.buildType === BuildType.dockerfile ? data.dockerBuildStage : null,
|
||||
herokuVersion:
|
||||
data.buildType === "heroku_buildpacks" ? data.herokuVersion : null,
|
||||
data.buildType === BuildType.heroku_buildpacks
|
||||
? data.herokuVersion
|
||||
: null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Build type saved");
|
||||
@@ -160,193 +208,143 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="buildType"
|
||||
defaultValue={form.control._defaultValues.buildType}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Build Type</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-1"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="dockerfile" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Dockerfile
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="railpack" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Railpack{" "}
|
||||
<Badge className="ml-1 text-xs px-1">New</Badge>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="nixpacks" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Nixpacks
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="heroku_buildpacks" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Heroku Buildpacks
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="paketo_buildpacks" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Paketo Buildpacks
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="static" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">Static</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Build Type</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-1"
|
||||
>
|
||||
{Object.entries(buildTypeDisplayMap).map(
|
||||
([value, label]) => (
|
||||
<FormItem
|
||||
key={value}
|
||||
className="flex items-center space-x-3 space-y-0"
|
||||
>
|
||||
<FormControl>
|
||||
<RadioGroupItem value={value} />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
{label}
|
||||
{value === BuildType.railpack && (
|
||||
<Badge className="ml-2 px-1 text-xs">New</Badge>
|
||||
)}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
),
|
||||
)}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{buildType === "heroku_buildpacks" && (
|
||||
{buildType === BuildType.heroku_buildpacks && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="herokuVersion"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Heroku Version (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"Heroku Version (Default: 24)"}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Heroku Version (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Heroku Version (Default: 24)"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{buildType === "dockerfile" && (
|
||||
{buildType === BuildType.dockerfile && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerfile"
|
||||
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 (
|
||||
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>
|
||||
<FormLabel>Docker File</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"Publish Directory"}
|
||||
placeholder="Path of your docker file"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerContextPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Docker Context Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Path of your docker context (default: .)"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<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">
|
||||
|
||||
@@ -41,6 +41,7 @@ import { toast } from "sonner";
|
||||
import { domain } from "@/server/db/validations/domain";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dices } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type z from "zod";
|
||||
|
||||
type Domain = z.infer<typeof domain>;
|
||||
@@ -83,6 +84,13 @@ export const AddDomain = ({
|
||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const { data: canGenerateTraefikMeDomains } =
|
||||
api.domain.canGenerateTraefikMeDomains.useQuery({
|
||||
serverId: application?.serverId || "",
|
||||
});
|
||||
|
||||
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
|
||||
|
||||
const form = useForm<Domain>({
|
||||
resolver: zodResolver(domain),
|
||||
defaultValues: {
|
||||
@@ -186,6 +194,21 @@ export const AddDomain = ({
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{!canGenerateTraefikMeDomains &&
|
||||
field.value.includes("traefik.me") && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{application?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to make your traefik.me domain work.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<FormLabel>Host</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Form } from "@/components/ui/form";
|
||||
import { Secrets } from "@/components/ui/secrets";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const addEnvironmentSchema = z.object({
|
||||
env: z.string(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -39,13 +41,11 @@ import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const BitbucketProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
|
||||
@@ -115,7 +115,11 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Username" autoComplete="username" {...field} />
|
||||
<Input
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -130,7 +134,12 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Password" autoComplete="one-time-code" {...field} type="password" />
|
||||
<Input
|
||||
placeholder="Password"
|
||||
autoComplete="one-time-code"
|
||||
{...field}
|
||||
type="password"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -26,15 +26,15 @@ import {
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
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 { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||
|
||||
const GitProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
Command,
|
||||
@@ -34,17 +36,15 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, 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";
|
||||
import Link from "next/link";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
|
||||
const GithubProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -35,17 +37,15 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, 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";
|
||||
import Link from "next/link";
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
|
||||
const GitlabProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { SaveDockerProvider } from "@/components/dashboard/application/general/generic/save-docker-provider";
|
||||
import { SaveGitProvider } from "@/components/dashboard/application/general/generic/save-git-provider";
|
||||
import { SaveGiteaProvider } from "@/components/dashboard/application/general/generic/save-gitea-provider";
|
||||
import { SaveGithubProvider } from "@/components/dashboard/application/general/generic/save-github-provider";
|
||||
import {
|
||||
BitbucketIcon,
|
||||
DockerIcon,
|
||||
GitIcon,
|
||||
GiteaIcon,
|
||||
GithubIcon,
|
||||
GitlabIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
@@ -18,7 +20,14 @@ import { SaveBitbucketProvider } from "./save-bitbucket-provider";
|
||||
import { SaveDragNDrop } from "./save-drag-n-drop";
|
||||
import { SaveGitlabProvider } from "./save-gitlab-provider";
|
||||
|
||||
type TabState = "github" | "docker" | "git" | "drop" | "gitlab" | "bitbucket";
|
||||
type TabState =
|
||||
| "github"
|
||||
| "docker"
|
||||
| "git"
|
||||
| "drop"
|
||||
| "gitlab"
|
||||
| "bitbucket"
|
||||
| "gitea";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
@@ -29,6 +38,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
||||
const { data: bitbucketProviders } =
|
||||
api.bitbucket.bitbucketProviders.useQuery();
|
||||
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
||||
|
||||
const { data: application } = api.application.one.useQuery({ applicationId });
|
||||
const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
|
||||
@@ -55,7 +65,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
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">
|
||||
<TabsTrigger
|
||||
value="github"
|
||||
@@ -78,6 +88,13 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
<BitbucketIcon className="size-4 text-current fill-current" />
|
||||
Bitbucket
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="gitea"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
<GiteaIcon className="size-4 text-current fill-current" />
|
||||
Gitea
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="docker"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
@@ -162,6 +179,26 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="gitea" className="w-full p-2">
|
||||
{giteaProviders && giteaProviders?.length > 0 ? (
|
||||
<SaveGiteaProvider applicationId={applicationId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||
<GiteaIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To deploy using Gitea, you need to configure your account
|
||||
first. Please, go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/git-providers"
|
||||
className="text-foreground"
|
||||
>
|
||||
Settings
|
||||
</Link>{" "}
|
||||
to do so.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="docker" className="w-full p-2">
|
||||
<SaveDockerProvider applicationId={applicationId} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -298,7 +298,11 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Preview deployments enabled");
|
||||
toast.success(
|
||||
checked
|
||||
? "Preview deployments enabled"
|
||||
: "Preview deployments disabled",
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { domainCompose } from "@/server/db/validations/domain";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type z from "zod";
|
||||
|
||||
type Domain = z.infer<typeof domainCompose>;
|
||||
@@ -102,6 +103,11 @@ export const AddDomainCompose = ({
|
||||
? api.domain.update.useMutation()
|
||||
: api.domain.create.useMutation();
|
||||
|
||||
const { data: canGenerateTraefikMeDomains } =
|
||||
api.domain.canGenerateTraefikMeDomains.useQuery({
|
||||
serverId: compose?.serverId || "",
|
||||
});
|
||||
|
||||
const form = useForm<Domain>({
|
||||
resolver: zodResolver(domainCompose),
|
||||
defaultValues: {
|
||||
@@ -313,6 +319,21 @@ export const AddDomainCompose = ({
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{!canGenerateTraefikMeDomains &&
|
||||
field.value.includes("traefik.me") && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{compose?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to make your traefik.me domain work.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<FormLabel>Host</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
|
||||
@@ -79,6 +79,22 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
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 (
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-4 ">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -39,13 +41,11 @@ import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const BitbucketProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -27,13 +28,12 @@ import {
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const GitProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -39,12 +40,11 @@ import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const GithubProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -39,13 +41,11 @@ import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const GitlabProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BitbucketIcon,
|
||||
GitIcon,
|
||||
GiteaIcon,
|
||||
GithubIcon,
|
||||
GitlabIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
@@ -14,10 +15,11 @@ import { ComposeFileEditor } from "../compose-file-editor";
|
||||
import { ShowConvertedCompose } from "../show-converted-compose";
|
||||
import { SaveBitbucketProviderCompose } from "./save-bitbucket-provider-compose";
|
||||
import { SaveGitProviderCompose } from "./save-git-provider-compose";
|
||||
import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose";
|
||||
import { SaveGithubProviderCompose } from "./save-github-provider-compose";
|
||||
import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose";
|
||||
|
||||
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket";
|
||||
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea";
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
@@ -27,9 +29,11 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
||||
const { data: bitbucketProviders } =
|
||||
api.bitbucket.bitbucketProviders.useQuery();
|
||||
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
||||
|
||||
const { data: compose } = api.compose.one.useQuery({ composeId });
|
||||
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
|
||||
|
||||
return (
|
||||
<Card className="group relative w-full bg-transparent">
|
||||
<CardHeader>
|
||||
@@ -54,21 +58,21 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
setSab(e as TabState);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||
<TabsList className="md:grid md:w-fit md:grid-cols-5 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||
<TabsList className="md:grid md:w-fit md:grid-cols-6 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
|
||||
<TabsTrigger
|
||||
value="github"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
<GithubIcon className="size-4 text-current fill-current" />
|
||||
Github
|
||||
GitHub
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="gitlab"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
<GitlabIcon className="size-4 text-current fill-current" />
|
||||
Gitlab
|
||||
GitLab
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="bitbucket"
|
||||
@@ -77,7 +81,12 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
<BitbucketIcon className="size-4 text-current fill-current" />
|
||||
Bitbucket
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="gitea"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
<GiteaIcon className="size-4 text-current fill-current" /> Gitea
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="git"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
@@ -89,11 +98,12 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
value="raw"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
<CodeIcon className="size-4 " />
|
||||
<CodeIcon className="size-4" />
|
||||
Raw
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="github" className="w-full p-2">
|
||||
{githubProviders && githubProviders?.length > 0 ? (
|
||||
<SaveGithubProviderCompose composeId={composeId} />
|
||||
@@ -154,6 +164,26 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="gitea" className="w-full p-2">
|
||||
{giteaProviders && giteaProviders?.length > 0 ? (
|
||||
<SaveGiteaProviderCompose composeId={composeId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||
<GiteaIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To deploy using Gitea, you need to configure your account
|
||||
first. Please, go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/git-providers"
|
||||
className="text-foreground"
|
||||
>
|
||||
Settings
|
||||
</Link>{" "}
|
||||
to do so.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="git" className="w-full p-2">
|
||||
<SaveGitProviderCompose composeId={composeId} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -147,7 +147,9 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Enable Isolated Deployment ({data?.appName})</FormLabel>
|
||||
<FormLabel>
|
||||
Enable Isolated Deployment ({data?.appName})
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Enable isolated deployment to the compose file.
|
||||
</FormDescription>
|
||||
|
||||
@@ -62,6 +62,11 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<AlertBlock type="info">
|
||||
Preview your docker-compose file with added domains. Note: At least
|
||||
one domain must be specified for this conversion to take effect.
|
||||
</AlertBlock>
|
||||
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
||||
@@ -61,7 +61,7 @@ type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
|
||||
|
||||
interface Props {
|
||||
databaseId: string;
|
||||
databaseType: "postgres" | "mariadb" | "mysql" | "mongo";
|
||||
databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
database: "",
|
||||
database: databaseType === "web-server" ? "dokploy" : "",
|
||||
destinationId: "",
|
||||
enabled: true,
|
||||
prefix: "/",
|
||||
@@ -112,7 +112,11 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
||||
? {
|
||||
mongoId: databaseId,
|
||||
}
|
||||
: undefined;
|
||||
: databaseType === "web-server"
|
||||
? {
|
||||
userId: databaseId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await createBackup({
|
||||
destinationId: data.destinationId,
|
||||
@@ -236,7 +240,11 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"dokploy"} {...field} />
|
||||
<Input
|
||||
disabled={databaseType === "web-server"}
|
||||
placeholder={"dokploy"}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -286,16 +294,21 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Keep the latest</FormLabel>
|
||||
<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>
|
||||
<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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -32,22 +35,20 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { debounce } from "lodash";
|
||||
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||
import { debounce } from "lodash";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 {
|
||||
databaseId: string;
|
||||
databaseType: Exclude<ServiceType, "application" | "redis">;
|
||||
databaseType: Exclude<ServiceType, "application" | "redis"> | "web-server";
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
const RestoreBackupSchema = z.object({
|
||||
@@ -76,9 +77,14 @@ const RestoreBackupSchema = z.object({
|
||||
|
||||
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 [search, setSearch] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
|
||||
const { data: destinations = [] } = api.destination.all.useQuery();
|
||||
|
||||
@@ -86,7 +92,7 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
|
||||
defaultValues: {
|
||||
destinationId: "",
|
||||
backupFile: "",
|
||||
databaseName: "",
|
||||
databaseName: databaseType === "web-server" ? "dokploy" : "",
|
||||
},
|
||||
resolver: zodResolver(RestoreBackupSchema),
|
||||
});
|
||||
@@ -94,13 +100,19 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
|
||||
const destionationId = form.watch("destinationId");
|
||||
|
||||
const debouncedSetSearch = debounce((value: string) => {
|
||||
setDebouncedSearchTerm(value);
|
||||
}, 150);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
}, 300);
|
||||
debouncedSetSearch(value);
|
||||
};
|
||||
|
||||
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
|
||||
{
|
||||
destinationId: destionationId,
|
||||
search,
|
||||
search: debouncedSearchTerm,
|
||||
serverId: serverId ?? "",
|
||||
},
|
||||
{
|
||||
enabled: isOpen && !!destionationId,
|
||||
@@ -278,7 +290,8 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search backup files..."
|
||||
onValueChange={debouncedSetSearch}
|
||||
value={search}
|
||||
onValueChange={handleSearchChange}
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoading ? (
|
||||
@@ -302,9 +315,13 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
|
||||
key={file}
|
||||
onSelect={() => {
|
||||
form.setValue("backupFile", file);
|
||||
setSearch(file);
|
||||
setDebouncedSearchTerm(file);
|
||||
}}
|
||||
>
|
||||
{file}
|
||||
<div className="flex w-full justify-between">
|
||||
<span>{file}</span>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
@@ -332,7 +349,11 @@ export const RestoreBackup = ({ databaseId, databaseType }: Props) => {
|
||||
<FormItem className="">
|
||||
<FormLabel>Database Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Enter database name" />
|
||||
<Input
|
||||
disabled={databaseType === "web-server"}
|
||||
{...field}
|
||||
placeholder="Enter database name"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -14,18 +14,18 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
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 { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||
import { AddBackup } from "./add-backup";
|
||||
import { UpdateBackup } from "./update-backup";
|
||||
import { RestoreBackup } from "./restore-backup";
|
||||
import { useState } from "react";
|
||||
import { UpdateBackup } from "./update-backup";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: Exclude<ServiceType, "application" | "redis">;
|
||||
type: Exclude<ServiceType, "application" | "redis"> | "web-server";
|
||||
}
|
||||
export const ShowBackups = ({ id, type }: Props) => {
|
||||
const [activeManualBackup, setActiveManualBackup] = useState<
|
||||
@@ -38,6 +38,7 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
"web-server": () => api.user.getBackups.useQuery(),
|
||||
};
|
||||
const { data } = api.destination.all.useQuery();
|
||||
const { data: postgres, refetch } = queryMap[type]
|
||||
@@ -49,6 +50,7 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
mysql: () => api.backup.manualBackupMySql.useMutation(),
|
||||
mariadb: () => api.backup.manualBackupMariadb.useMutation(),
|
||||
mongo: () => api.backup.manualBackupMongo.useMutation(),
|
||||
"web-server": () => api.backup.manualBackupWebServer.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
|
||||
@@ -64,7 +66,10 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<CardTitle className="text-xl">Backups</CardTitle>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Database className="size-6 text-muted-foreground" />
|
||||
Backups
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add backups to your database to save the data to a different
|
||||
provider.
|
||||
@@ -73,8 +78,18 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
|
||||
{postgres && postgres?.backups?.length > 0 && (
|
||||
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
|
||||
<AddBackup databaseId={id} databaseType={type} refetch={refetch} />
|
||||
<RestoreBackup databaseId={id} databaseType={type} />
|
||||
{type !== "web-server" && (
|
||||
<AddBackup
|
||||
databaseId={id}
|
||||
databaseType={type}
|
||||
refetch={refetch}
|
||||
/>
|
||||
)}
|
||||
<RestoreBackup
|
||||
databaseId={id}
|
||||
databaseType={type}
|
||||
serverId={"serverId" in postgres ? postgres.serverId : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
@@ -108,7 +123,13 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
databaseType={type}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<RestoreBackup databaseId={id} databaseType={type} />
|
||||
<RestoreBackup
|
||||
databaseId={id}
|
||||
databaseType={type}
|
||||
serverId={
|
||||
"serverId" in postgres ? postgres.serverId : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -92,7 +92,9 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
||||
enabled: backup.enabled || false,
|
||||
prefix: backup.prefix,
|
||||
schedule: backup.schedule,
|
||||
keepLatestCount: backup.keepLatestCount ? Number(backup.keepLatestCount) : undefined,
|
||||
keepLatestCount: backup.keepLatestCount
|
||||
? Number(backup.keepLatestCount)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, backup]);
|
||||
@@ -274,10 +276,15 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Keep the latest</FormLabel>
|
||||
<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>
|
||||
<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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -27,145 +27,149 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
mariadbId: string;
|
||||
mariadbId: string;
|
||||
}
|
||||
export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
|
||||
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
|
||||
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mariadbId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mariadbId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
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());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseName,
|
||||
data?.databaseUser,
|
||||
getIp,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4 ">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3306"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */}
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseName,
|
||||
data?.databaseUser,
|
||||
getIp,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4 ">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3306"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */}
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,144 +27,148 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
mongoId: string;
|
||||
mongoId: string;
|
||||
}
|
||||
export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mongo.one.useQuery({ mongoId });
|
||||
const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mongo.one.useQuery({ mongoId });
|
||||
const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mongoId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mongoId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
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());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseUser,
|
||||
getIp,
|
||||
]);
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseUser,
|
||||
getIp,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="27017"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="27017"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,10 +3,10 @@ import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
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 { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
interface Props {
|
||||
mongoId: string;
|
||||
mongoId: string;
|
||||
}
|
||||
|
||||
export const ShowGeneralMongo = ({ mongoId }: Props) => {
|
||||
const { data, refetch } = api.mongo.one.useQuery(
|
||||
{
|
||||
mongoId,
|
||||
},
|
||||
{ enabled: !!mongoId }
|
||||
);
|
||||
const { data, refetch } = api.mongo.one.useQuery(
|
||||
{
|
||||
mongoId,
|
||||
},
|
||||
{ enabled: !!mongoId },
|
||||
);
|
||||
|
||||
const { mutateAsync: reload, isLoading: isReloading } =
|
||||
api.mongo.reload.useMutation();
|
||||
const { mutateAsync: reload, isLoading: isReloading } =
|
||||
api.mongo.reload.useMutation();
|
||||
|
||||
const { mutateAsync: start, isLoading: isStarting } =
|
||||
api.mongo.start.useMutation();
|
||||
const { mutateAsync: start, isLoading: isStarting } =
|
||||
api.mongo.start.useMutation();
|
||||
|
||||
const { mutateAsync: stop, isLoading: isStopping } =
|
||||
api.mongo.stop.useMutation();
|
||||
const { mutateAsync: stop, isLoading: isStopping } =
|
||||
api.mongo.stop.useMutation();
|
||||
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
api.mongo.deployWithLogs.useSubscription(
|
||||
{
|
||||
mongoId: mongoId,
|
||||
},
|
||||
{
|
||||
enabled: isDeploying,
|
||||
onData(log) {
|
||||
if (!isDrawerOpen) {
|
||||
setIsDrawerOpen(true);
|
||||
}
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
api.mongo.deployWithLogs.useSubscription(
|
||||
{
|
||||
mongoId: mongoId,
|
||||
},
|
||||
{
|
||||
enabled: isDeploying,
|
||||
onData(log) {
|
||||
if (!isDrawerOpen) {
|
||||
setIsDrawerOpen(true);
|
||||
}
|
||||
|
||||
if (log === "Deployment completed successfully!") {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
if (log === "Deployment completed successfully!") {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
|
||||
const parsedLogs = parseLogs(log);
|
||||
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
|
||||
},
|
||||
onError(error) {
|
||||
console.error("Deployment logs error:", error);
|
||||
setIsDeploying(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Deploy Mongo"
|
||||
description="Are you sure you want to deploy this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the MongoDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Mongo"
|
||||
description="Are you sure you want to reload this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mongoId: mongoId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MongoDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Mongo"
|
||||
description="Are you sure you want to start this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MongoDB database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Mongo"
|
||||
description="Are you sure you want to stop this mongo?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MongoDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</DialogAction>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-2 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Terminal className="size-4" />
|
||||
Open Terminal
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Open a terminal to the MongoDB container</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</DockerTerminalModal>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
setFilteredLogs([]);
|
||||
setIsDeploying(false);
|
||||
refetch();
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
const parsedLogs = parseLogs(log);
|
||||
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
|
||||
},
|
||||
onError(error) {
|
||||
console.error("Deployment logs error:", error);
|
||||
setIsDeploying(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Deploy Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<DialogAction
|
||||
title="Deploy Mongo"
|
||||
description="Are you sure you want to deploy this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Downloads and sets up the MongoDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Mongo"
|
||||
description="Are you sure you want to reload this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
mongoId: mongoId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Restart the MongoDB service without rebuilding</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Mongo"
|
||||
description="Are you sure you want to start this mongo?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await start({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo started successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error starting Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isStarting}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle2 className="size-4 mr-1" />
|
||||
Start
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Start the MongoDB database (requires a previous
|
||||
successful setup)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
<DialogAction
|
||||
title="Stop Mongo"
|
||||
description="Are you sure you want to stop this mongo?"
|
||||
onClick={async () => {
|
||||
await stop({
|
||||
mongoId: mongoId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Mongo stopped successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error stopping Mongo");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
isLoading={isStopping}
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Ban className="size-4 mr-1" />
|
||||
Stop
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Stop the currently running MongoDB database</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Terminal className="size-4 mr-1" />
|
||||
Open Terminal
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Open a terminal to the MongoDB container</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
setFilteredLogs([]);
|
||||
setIsDeploying(false);
|
||||
refetch();
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,144 +27,148 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
mysqlId: string;
|
||||
mysqlId: string;
|
||||
}
|
||||
export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
|
||||
const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
|
||||
const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mysqlId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mysqlId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
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());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
data?.databaseName,
|
||||
data?.databaseUser,
|
||||
form,
|
||||
getIp,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3306"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput disabled value={connectionUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
data?.databaseName,
|
||||
data?.databaseUser,
|
||||
form,
|
||||
getIp,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3306"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput disabled value={connectionUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,146 +27,150 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
postgresId: string;
|
||||
postgresId: string;
|
||||
}
|
||||
export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.postgres.one.useQuery({ postgresId });
|
||||
const { mutateAsync, isLoading } =
|
||||
api.postgres.saveExternalPort.useMutation();
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.postgres.one.useQuery({ postgresId });
|
||||
const { mutateAsync, isLoading } =
|
||||
api.postgres.saveExternalPort.useMutation();
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
postgresId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
postgresId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
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());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseName,
|
||||
getIp,
|
||||
]);
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseName,
|
||||
getIp,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="5432"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="5432"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,58 +5,58 @@ import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
postgresId: string;
|
||||
postgresId: string;
|
||||
}
|
||||
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
const { data } = api.postgres.one.useQuery({ postgresId });
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Internal Credentials</CardTitle>
|
||||
</CardHeader>
|
||||
<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="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Database Name</Label>
|
||||
<Input disabled value={data?.databaseName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
value={data?.databasePassword}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Port (Container)</Label>
|
||||
<Input disabled value="5432" />
|
||||
</div>
|
||||
const { data } = api.postgres.one.useQuery({ postgresId });
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Internal Credentials</CardTitle>
|
||||
</CardHeader>
|
||||
<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="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Database Name</Label>
|
||||
<Input disabled value={data?.databaseName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
value={data?.databasePassword}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Port (Container)</Label>
|
||||
<Input disabled value="5432" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Connection URL </Label>
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Connection URL </Label>
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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
|
||||
|
||||
@@ -28,139 +28,139 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const updatePostgresSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type UpdatePostgres = z.infer<typeof updatePostgresSchema>;
|
||||
|
||||
interface Props {
|
||||
postgresId: string;
|
||||
postgresId: string;
|
||||
}
|
||||
|
||||
export const UpdatePostgres = ({ postgresId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isLoading } =
|
||||
api.postgres.update.useMutation();
|
||||
const { data } = api.postgres.one.useQuery(
|
||||
{
|
||||
postgresId,
|
||||
},
|
||||
{
|
||||
enabled: !!postgresId,
|
||||
}
|
||||
);
|
||||
const form = useForm<UpdatePostgres>({
|
||||
defaultValues: {
|
||||
description: data?.description ?? "",
|
||||
name: data?.name ?? "",
|
||||
},
|
||||
resolver: zodResolver(updatePostgresSchema),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
description: data.description ?? "",
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isLoading } =
|
||||
api.postgres.update.useMutation();
|
||||
const { data } = api.postgres.one.useQuery(
|
||||
{
|
||||
postgresId,
|
||||
},
|
||||
{
|
||||
enabled: !!postgresId,
|
||||
},
|
||||
);
|
||||
const form = useForm<UpdatePostgres>({
|
||||
defaultValues: {
|
||||
description: data?.description ?? "",
|
||||
name: data?.name ?? "",
|
||||
},
|
||||
resolver: zodResolver(updatePostgresSchema),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
description: data.description ?? "",
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: UpdatePostgres) => {
|
||||
await mutateAsync({
|
||||
name: formData.name,
|
||||
postgresId: postgresId,
|
||||
description: formData.description || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Postgres updated successfully");
|
||||
utils.postgres.one.invalidate({
|
||||
postgresId: postgresId,
|
||||
});
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Postgres");
|
||||
})
|
||||
.finally(() => {});
|
||||
};
|
||||
const onSubmit = async (formData: UpdatePostgres) => {
|
||||
await mutateAsync({
|
||||
name: formData.name,
|
||||
postgresId: postgresId,
|
||||
description: formData.description || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Postgres updated successfully");
|
||||
utils.postgres.one.invalidate({
|
||||
postgresId: postgresId,
|
||||
});
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Postgres");
|
||||
})
|
||||
.finally(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modify Postgres</DialogTitle>
|
||||
<DialogDescription>Update the Postgres data</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modify Postgres</DialogTitle>
|
||||
<DialogDescription>Update the Postgres data</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid items-center gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-update-postgres"
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid items-center gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-update-postgres"
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description about your project..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description about your project..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-postgres"
|
||||
type="submit"
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-postgres"
|
||||
type="submit"
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
SearchIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
|
||||
@@ -307,7 +307,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
>
|
||||
{templates?.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
key={template?.id}
|
||||
className={cn(
|
||||
"flex flex-col border rounded-lg overflow-hidden relative",
|
||||
viewMode === "icon" && "h-[200px]",
|
||||
@@ -315,7 +315,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
)}
|
||||
>
|
||||
<Badge className="absolute top-2 right-2" variant="blue">
|
||||
{template.version}
|
||||
{template?.version}
|
||||
</Badge>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -324,21 +324,21 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
)}
|
||||
>
|
||||
<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(
|
||||
"object-contain",
|
||||
viewMode === "detailed" ? "size-24" : "size-16",
|
||||
)}
|
||||
alt={template.name}
|
||||
alt={template?.name}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-sm font-medium line-clamp-1">
|
||||
{template.name}
|
||||
{template?.name}
|
||||
</span>
|
||||
{viewMode === "detailed" &&
|
||||
template.tags.length > 0 && (
|
||||
template?.tags?.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{template.tags.map((tag) => (
|
||||
{template?.tags?.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="green"
|
||||
@@ -356,7 +356,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
{viewMode === "detailed" && (
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{template.description}
|
||||
{template?.description}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
@@ -372,25 +372,27 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
>
|
||||
{viewMode === "detailed" && (
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={template.links.github}
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<GithubIcon className="size-5" />
|
||||
</Link>
|
||||
{template.links.website && (
|
||||
{template?.links?.github && (
|
||||
<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"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Globe className="size-5" />
|
||||
</Link>
|
||||
)}
|
||||
{template.links.docs && (
|
||||
{template?.links?.docs && (
|
||||
<Link
|
||||
href={template.links.docs}
|
||||
href={template?.links?.docs}
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
@@ -419,7 +421,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will create an application from the{" "}
|
||||
{template.name} template and add it to your
|
||||
{template?.name} template and add it to your
|
||||
project.
|
||||
</AlertDialogDescription>
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
|
||||
const examples = [
|
||||
"Make a personal blog",
|
||||
@@ -23,7 +22,7 @@ const examples = [
|
||||
"Sendgrid service opensource analogue",
|
||||
];
|
||||
|
||||
export const StepOne = ({ nextStep, setTemplateInfo, templateInfo }: any) => {
|
||||
export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
|
||||
// Get servers from the API
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
|
||||
|
||||
172
apps/dokploy/components/dashboard/project/duplicate-project.tsx
Normal file
172
apps/dokploy/components/dashboard/project/duplicate-project.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -31,9 +31,14 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const AddProjectSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, {
|
||||
message: "Name is required",
|
||||
})
|
||||
.regex(/^[a-zA-Z]/, {
|
||||
message: "Project name cannot start with a number",
|
||||
}),
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
|
||||
@@ -115,7 +115,7 @@ export const ShowProjects = () => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 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) => {
|
||||
const emptyServices =
|
||||
project?.mariadb.length === 0 &&
|
||||
@@ -186,7 +186,9 @@ export const ShowProjects = () => {
|
||||
target="_blank"
|
||||
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" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -222,7 +224,9 @@ export const ShowProjects = () => {
|
||||
target="_blank"
|
||||
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" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -27,138 +27,142 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
redisId: string;
|
||||
redisId: string;
|
||||
}
|
||||
export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.redis.one.useQuery({ redisId });
|
||||
const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.redis.one.useQuery({ redisId });
|
||||
const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
redisId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
redisId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const _hostname = window.location.hostname;
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const _hostname = window.location.hostname;
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `redis://default:${data?.databasePassword}@${getIp}:${port}`;
|
||||
};
|
||||
return `redis://default:${data?.databasePassword}@${getIp}:${port}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [data?.appName, data?.externalPort, data?.databasePassword, form, getIp]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="6379"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [data?.appName, data?.externalPort, data?.databasePassword, form, getIp]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="6379"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
|
||||
@@ -25,13 +25,13 @@ import {
|
||||
import { type RouterOutputs, api } from "@/utils/api";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ArrowDownUp,
|
||||
AlertCircle,
|
||||
InfoIcon,
|
||||
ArrowDownUp,
|
||||
Calendar as CalendarIcon,
|
||||
InfoIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { RequestDistributionChart } from "./request-distribution-chart";
|
||||
import { RequestsTable } from "./requests-table";
|
||||
|
||||
@@ -55,7 +55,7 @@ export const AiForm = () => {
|
||||
key={config.aiId}
|
||||
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>
|
||||
<span className="text-sm font-medium">
|
||||
{config.name}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -17,22 +25,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "sonner";
|
||||
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({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -7,13 +9,11 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
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 { toast } from "sonner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { AddApiKey } from "./add-api-key";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const ShowApiKeys = () => {
|
||||
const { data, refetch } = api.user.get.useQuery();
|
||||
|
||||
@@ -70,7 +70,7 @@ export const ShowCertificates = () => {
|
||||
key={certificate.certificateId}
|
||||
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 gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -48,6 +49,10 @@ export const AddNode = ({ serverId }: Props) => {
|
||||
Architecture
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
<AlertBlock type="warning">
|
||||
Make sure you use the same architecture as the node you are
|
||||
adding.
|
||||
</AlertBlock>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -56,10 +61,10 @@ export const AddNode = ({ serverId }: Props) => {
|
||||
<TabsTrigger value="worker">Worker</TabsTrigger>
|
||||
<TabsTrigger value="manager">Manager</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="worker" className="pt-4">
|
||||
<TabsContent value="worker" className="pt-4 overflow-hidden">
|
||||
<AddWorker serverId={serverId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="manager" className="pt-4">
|
||||
<TabsContent value="manager" className="pt-4 overflow-hidden">
|
||||
<AddManager serverId={serverId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
DialogDescription,
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { CopyIcon, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
@@ -14,56 +15,66 @@ interface Props {
|
||||
}
|
||||
|
||||
export const AddManager = ({ serverId }: Props) => {
|
||||
const { data } = api.cluster.addManager.useQuery({ serverId });
|
||||
const { data, isLoading, error, isError } = api.cluster.addManager.useQuery({
|
||||
serverId,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a new manager</DialogTitle>
|
||||
<DialogDescription>Add a new manager</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>1. Go to your new server and run the following command</span>
|
||||
<span className="bg-muted rounded-lg p-2 flex justify-between">
|
||||
curl https://get.docker.com | sh -s -- --version {data?.version}
|
||||
<button
|
||||
type="button"
|
||||
className="self-center"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a new manager</DialogTitle>
|
||||
<DialogDescription>Add a new manager</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-full animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>
|
||||
1. Go to your new server and run the following command
|
||||
</span>
|
||||
<span className="bg-muted rounded-lg p-2 flex justify-between">
|
||||
curl https://get.docker.com | sh -s -- --version {data?.version}
|
||||
<button
|
||||
type="button"
|
||||
className="self-center"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>
|
||||
2. Run the following command to add the node(manager) to your
|
||||
cluster
|
||||
</span>
|
||||
<span className="bg-muted rounded-lg p-2 flex">
|
||||
{data?.command}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copy(data?.command || "");
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>
|
||||
2. Run the following command to add the node(manager) to your
|
||||
cluster
|
||||
</span>
|
||||
|
||||
<span className="bg-muted rounded-lg p-2 flex">
|
||||
{data?.command}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copy(data?.command || "");
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ShowNodesModal = ({ serverId }: Props) => {
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Show Nodes
|
||||
Show Swarm Nodes
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">
|
||||
|
||||
@@ -35,9 +35,9 @@ import { api } from "@/utils/api";
|
||||
import {
|
||||
Boxes,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
LockIcon,
|
||||
MoreHorizontal,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AddNode } from "./add-node";
|
||||
@@ -144,7 +144,7 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<ShowNodeData data={node} />
|
||||
{node?.ManagerStatus?.Leader && (
|
||||
{!node?.ManagerStatus?.Leader && (
|
||||
<DialogAction
|
||||
title="Delete Node"
|
||||
description="Are you sure you want to delete this node from the cluster?"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
DialogDescription,
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { CopyIcon, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
@@ -14,54 +15,62 @@ interface Props {
|
||||
}
|
||||
|
||||
export const AddWorker = ({ serverId }: Props) => {
|
||||
const { data } = api.cluster.addWorker.useQuery({ serverId });
|
||||
const { data, isLoading, error, isError } = api.cluster.addWorker.useQuery({
|
||||
serverId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a new worker</DialogTitle>
|
||||
<DialogDescription>Add a new worker</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>1. Go to your new server and run the following command</span>
|
||||
<span className="bg-muted rounded-lg p-2 flex justify-between">
|
||||
curl https://get.docker.com | sh -s -- --version {data?.version}
|
||||
<button
|
||||
type="button"
|
||||
className="self-center"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a new worker</DialogTitle>
|
||||
<DialogDescription>Add a new worker</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-full animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>1. Go to your new server and run the following command</span>
|
||||
<span className="bg-muted rounded-lg p-2 flex justify-between">
|
||||
curl https://get.docker.com | sh -s -- --version {data?.version}
|
||||
<button
|
||||
type="button"
|
||||
className="self-center"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>
|
||||
2. Run the following command to add the node(worker) to your cluster
|
||||
</span>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>
|
||||
2. Run the following command to add the node(worker) to your
|
||||
cluster
|
||||
</span>
|
||||
|
||||
<span className="bg-muted rounded-lg p-2 flex">
|
||||
{data?.command}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copy(data?.command || "");
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
<span className="bg-muted rounded-lg p-2 flex">
|
||||
{data?.command}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copy(data?.command || "");
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export const ShowRegistry = () => {
|
||||
key={registry.registryId}
|
||||
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 gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -55,7 +55,7 @@ export const ShowDestinations = () => {
|
||||
key={destination.destinationId}
|
||||
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">
|
||||
<span className="text-sm">
|
||||
{index + 1}. {destination.name}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -248,7 +248,9 @@ export const AddGitlabProvider = () => {
|
||||
name="groupName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group Name (Optional)</FormLabel>
|
||||
<FormLabel>
|
||||
Group Name (Optional, Comma-Separated List)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||
|
||||
@@ -156,7 +156,9 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
name="groupName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group Name (Optional)</FormLabel>
|
||||
<FormLabel>
|
||||
Group Name (Optional, Comma-Separated List)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BitbucketIcon,
|
||||
GiteaIcon,
|
||||
GithubIcon,
|
||||
GitlabIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
@@ -26,6 +27,8 @@ import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
|
||||
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
|
||||
import { AddGiteaProvider } from "./gitea/add-gitea-provider";
|
||||
import { EditGiteaProvider } from "./gitea/edit-gitea-provider";
|
||||
import { AddGithubProvider } from "./github/add-github-provider";
|
||||
import { EditGithubProvider } from "./github/edit-github-provider";
|
||||
import { AddGitlabProvider } from "./gitlab/add-gitlab-provider";
|
||||
@@ -36,19 +39,18 @@ export const ShowGitProviders = () => {
|
||||
const { mutateAsync, isLoading: isRemoving } =
|
||||
api.gitProvider.remove.useMutation();
|
||||
const url = useUrl();
|
||||
|
||||
const getGitlabUrl = (
|
||||
clientId: string,
|
||||
gitlabId: string,
|
||||
gitlabUrl: string,
|
||||
) => {
|
||||
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
|
||||
|
||||
const scope = "api read_user read_repository";
|
||||
|
||||
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
|
||||
|
||||
return authUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
@@ -82,6 +84,7 @@ export const ShowGitProviders = () => {
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,6 +100,7 @@ export const ShowGitProviders = () => {
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,13 +111,16 @@ export const ShowGitProviders = () => {
|
||||
const isGitlab = gitProvider.providerType === "gitlab";
|
||||
const isBitbucket =
|
||||
gitProvider.providerType === "bitbucket";
|
||||
const isGitea = gitProvider.providerType === "gitea";
|
||||
|
||||
const haveGithubRequirements =
|
||||
gitProvider.providerType === "github" &&
|
||||
isGithub &&
|
||||
gitProvider.github?.githubPrivateKey &&
|
||||
gitProvider.github?.githubAppId &&
|
||||
gitProvider.github?.githubInstallationId;
|
||||
|
||||
const haveGitlabRequirements =
|
||||
isGitlab &&
|
||||
gitProvider.gitlab?.accessToken &&
|
||||
gitProvider.gitlab?.refreshToken;
|
||||
|
||||
@@ -122,18 +129,19 @@ export const ShowGitProviders = () => {
|
||||
key={gitProvider.gitProviderId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex flex-col items-center justify-between">
|
||||
<div className="flex gap-2 flex-row items-center">
|
||||
{gitProvider.providerType === "github" && (
|
||||
{isGithub && (
|
||||
<GithubIcon className="size-5" />
|
||||
)}
|
||||
{gitProvider.providerType === "gitlab" && (
|
||||
{isGitlab && (
|
||||
<GitlabIcon className="size-5" />
|
||||
)}
|
||||
{gitProvider.providerType === "bitbucket" && (
|
||||
{isBitbucket && (
|
||||
<BitbucketIcon className="size-5" />
|
||||
)}
|
||||
{isGitea && <GiteaIcon className="size-5" />}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{gitProvider.name}
|
||||
@@ -194,26 +202,33 @@ export const ShowGitProviders = () => {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGithub && haveGithubRequirements && (
|
||||
<EditGithubProvider
|
||||
githubId={gitProvider.github.githubId}
|
||||
githubId={gitProvider.github?.githubId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitlab && (
|
||||
<EditGitlabProvider
|
||||
gitlabId={gitProvider.gitlab.gitlabId}
|
||||
gitlabId={gitProvider.gitlab?.gitlabId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBitbucket && (
|
||||
<EditBitbucketProvider
|
||||
bitbucketId={
|
||||
gitProvider.bitbucket.bitbucketId
|
||||
gitProvider.bitbucket?.bitbucketId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitea && (
|
||||
<EditGiteaProvider
|
||||
giteaId={gitProvider.gitea?.giteaId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogAction
|
||||
title="Delete Git Provider"
|
||||
description="Are you sure you want to delete this Git Provider?"
|
||||
@@ -238,7 +253,7 @@ export const ShowGitProviders = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
|
||||
@@ -663,13 +663,16 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value) {
|
||||
if (value === "") {
|
||||
field.onChange(undefined);
|
||||
} else {
|
||||
const port = Number.parseInt(value);
|
||||
if (port > 0 && port < 65536) {
|
||||
field.onChange(port);
|
||||
}
|
||||
}
|
||||
}}
|
||||
value={field.value || ""}
|
||||
type="number"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ShowNotifications = () => {
|
||||
key={notification.notificationId}
|
||||
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">
|
||||
{notification.notificationType === "slack" && (
|
||||
<div className="flex items-center justify-center rounded-lg">
|
||||
|
||||
@@ -36,6 +36,7 @@ const PasswordSchema = z.object({
|
||||
password: z.string().min(8, {
|
||||
message: "Password is required",
|
||||
}),
|
||||
issuer: z.string().optional(),
|
||||
});
|
||||
|
||||
const PinSchema = z.object({
|
||||
@@ -60,12 +61,86 @@ export const Enable2FA = () => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [step, setStep] = useState<"password" | "verify">("password");
|
||||
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||
const [otpValue, setOtpValue] = useState("");
|
||||
|
||||
const handleVerifySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const result = await authClient.twoFactor.verifyTotp({
|
||||
code: otpValue,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
|
||||
toast.error("Invalid verification code");
|
||||
return;
|
||||
}
|
||||
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error("No response received from server");
|
||||
}
|
||||
|
||||
toast.success("2FA configured successfully");
|
||||
utils.user.get.invalidate();
|
||||
setIsDialogOpen(false);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
const errorMessage =
|
||||
error.message === "Failed to fetch"
|
||||
? "Connection error. Please check your internet connection."
|
||||
: error.message;
|
||||
|
||||
toast.error(errorMessage);
|
||||
} else {
|
||||
toast.error("Error verifying 2FA code", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const passwordForm = useForm<PasswordForm>({
|
||||
resolver: zodResolver(PasswordSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const pinForm = useForm<PinForm>({
|
||||
resolver: zodResolver(PinSchema),
|
||||
defaultValues: {
|
||||
pin: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) {
|
||||
setStep("password");
|
||||
setData(null);
|
||||
setBackupCodes([]);
|
||||
setOtpValue("");
|
||||
passwordForm.reset({
|
||||
password: "",
|
||||
issuer: "",
|
||||
});
|
||||
}
|
||||
}, [isDialogOpen, passwordForm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "verify") {
|
||||
setOtpValue("");
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
const handlePasswordSubmit = async (formData: PasswordForm) => {
|
||||
setIsPasswordLoading(true);
|
||||
try {
|
||||
const { data: enableData, error } = await authClient.twoFactor.enable({
|
||||
password: formData.password,
|
||||
issuer: formData.issuer,
|
||||
});
|
||||
|
||||
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 (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -217,6 +223,27 @@ export const Enable2FA = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="issuer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issuer</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter your issuer"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use a custom issuer to identify the service you're
|
||||
authenticating with.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
@@ -228,11 +255,7 @@ export const Enable2FA = () => {
|
||||
</Form>
|
||||
) : (
|
||||
<Form {...pinForm}>
|
||||
<form
|
||||
id="pin-form"
|
||||
onSubmit={pinForm.handleSubmit(handleVerifySubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<form onSubmit={handleVerifySubmit} className="space-y-6">
|
||||
<div className="flex flex-col gap-6 justify-center items-center">
|
||||
{data?.qrCodeUrl ? (
|
||||
<>
|
||||
@@ -284,36 +307,33 @@ export const Enable2FA = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={pinForm.control}
|
||||
name="pin"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-center items-center">
|
||||
<FormLabel>Verification Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP maxLength={6} {...field}>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<FormLabel>Verification Code</FormLabel>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otpValue}
|
||||
onChange={setOtpValue}
|
||||
autoComplete="off"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<FormDescription>
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isPasswordLoading}
|
||||
disabled={otpValue.length !== 6}
|
||||
>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
|
||||
@@ -56,6 +56,7 @@ const randomImages = [
|
||||
export const ProfileForm = () => {
|
||||
const _utils = api.useUtils();
|
||||
const { data, refetch, isLoading } = api.user.get.useQuery();
|
||||
|
||||
const {
|
||||
mutateAsync,
|
||||
isLoading: isUpdating,
|
||||
@@ -84,12 +85,17 @@ export const ProfileForm = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
email: data?.user?.email || "",
|
||||
password: "",
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: "",
|
||||
});
|
||||
form.reset(
|
||||
{
|
||||
email: data?.user?.email || "",
|
||||
password: form.getValues("password") || "",
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: form.getValues("currentPassword") || "",
|
||||
},
|
||||
{
|
||||
keepValues: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (data.user.email) {
|
||||
generateSHA256Hash(data.user.email).then((hash) => {
|
||||
@@ -97,8 +103,7 @@ export const ProfileForm = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
form.reset();
|
||||
}, [form, form.reset, data]);
|
||||
}, [form, data]);
|
||||
|
||||
const onSubmit = async (values: Profile) => {
|
||||
await mutateAsync({
|
||||
@@ -110,7 +115,12 @@ export const ProfileForm = () => {
|
||||
.then(async () => {
|
||||
await refetch();
|
||||
toast.success("Profile Updated");
|
||||
form.reset();
|
||||
form.reset({
|
||||
email: values.email,
|
||||
password: "",
|
||||
image: values.image,
|
||||
currentPassword: "",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the profile");
|
||||
|
||||
@@ -25,7 +25,7 @@ export const SecurityAudit = ({ serverId }: Props) => {
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
const _utils = api.useUtils();
|
||||
|
||||
return (
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -36,10 +36,12 @@ export const SecurityAudit = ({ serverId }: Props) => {
|
||||
<div className="flex items-center gap-2">
|
||||
<LockKeyhole className="size-5" />
|
||||
<CardTitle className="text-xl">
|
||||
Setup Security Sugestions
|
||||
Setup Security Suggestions
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Check the security sugestions</CardDescription>
|
||||
<CardDescription>
|
||||
Check the security suggestions
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isRefreshing}
|
||||
@@ -120,36 +122,36 @@ export const SecurityAudit = ({ serverId }: Props) => {
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
label="Enabled"
|
||||
isEnabled={data?.ssh.enabled}
|
||||
isEnabled={data?.ssh?.enabled}
|
||||
description={
|
||||
data?.ssh.enabled
|
||||
data?.ssh?.enabled
|
||||
? "Enabled"
|
||||
: "Not Enabled (SSH should be enabled)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Key Auth"
|
||||
isEnabled={data?.ssh.keyAuth}
|
||||
isEnabled={data?.ssh?.keyAuth}
|
||||
description={
|
||||
data?.ssh.keyAuth
|
||||
data?.ssh?.keyAuth
|
||||
? "Enabled (Recommended)"
|
||||
: "Not Enabled (Key Authentication should be enabled)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Password Auth"
|
||||
isEnabled={data?.ssh.passwordAuth === "no"}
|
||||
isEnabled={data?.ssh?.passwordAuth === "no"}
|
||||
description={
|
||||
data?.ssh.passwordAuth === "no"
|
||||
data?.ssh?.passwordAuth === "no"
|
||||
? "Disabled (Recommended)"
|
||||
: "Enabled (Password Authentication should be disabled)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Use PAM"
|
||||
isEnabled={data?.ssh.usePam === "no"}
|
||||
isEnabled={data?.ssh?.usePam === "no"}
|
||||
description={
|
||||
data?.ssh.usePam === "no"
|
||||
data?.ssh?.usePam === "no"
|
||||
? "Disabled (Recommended for key-based auth)"
|
||||
: "Enabled (Should be disabled when using key-based auth)"
|
||||
}
|
||||
@@ -166,9 +168,9 @@ export const SecurityAudit = ({ serverId }: Props) => {
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
label="Installed"
|
||||
isEnabled={data?.fail2ban.installed}
|
||||
isEnabled={data?.fail2ban?.installed}
|
||||
description={
|
||||
data?.fail2ban.installed
|
||||
data?.fail2ban?.installed
|
||||
? "Installed (Recommended)"
|
||||
: "Not Installed (Fail2Ban should be installed for protection against brute force attacks)"
|
||||
}
|
||||
@@ -176,18 +178,18 @@ export const SecurityAudit = ({ serverId }: Props) => {
|
||||
|
||||
<StatusRow
|
||||
label="Enabled"
|
||||
isEnabled={data?.fail2ban.enabled}
|
||||
isEnabled={data?.fail2ban?.enabled}
|
||||
description={
|
||||
data?.fail2ban.enabled
|
||||
data?.fail2ban?.enabled
|
||||
? "Enabled (Recommended)"
|
||||
: "Not Enabled (Fail2Ban service should be enabled)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Active"
|
||||
isEnabled={data?.fail2ban.active}
|
||||
isEnabled={data?.fail2ban?.active}
|
||||
description={
|
||||
data?.fail2ban.active
|
||||
data?.fail2ban?.active
|
||||
? "Active (Recommended)"
|
||||
: "Not Active (Fail2Ban service should be running)"
|
||||
}
|
||||
@@ -195,9 +197,9 @@ export const SecurityAudit = ({ serverId }: Props) => {
|
||||
|
||||
<StatusRow
|
||||
label="SSH Protection"
|
||||
isEnabled={data?.fail2ban.sshEnabled === "true"}
|
||||
isEnabled={data?.fail2ban?.sshEnabled === "true"}
|
||||
description={
|
||||
data?.fail2ban.sshEnabled === "true"
|
||||
data?.fail2ban?.sshEnabled === "true"
|
||||
? "Enabled (Recommended)"
|
||||
: "Not Enabled (SSH protection should be enabled to prevent brute force attacks)"
|
||||
}
|
||||
@@ -205,11 +207,11 @@ export const SecurityAudit = ({ serverId }: Props) => {
|
||||
|
||||
<StatusRow
|
||||
label="SSH Mode"
|
||||
isEnabled={data?.fail2ban.sshMode === "aggressive"}
|
||||
isEnabled={data?.fail2ban?.sshMode === "aggressive"}
|
||||
description={
|
||||
data?.fail2ban.sshMode === "aggressive"
|
||||
data?.fail2ban?.sshMode === "aggressive"
|
||||
? "Aggressive Mode (Recommended)"
|
||||
: `Mode: ${data?.fail2ban.sshMode || "Not Set"} (Aggressive mode recommended for better protection)`
|
||||
: `Mode: ${data?.fail2ban?.sshMode || "Not Set"} (Aggressive mode recommended for better protection)`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useTranslation } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { toast } from "sonner";
|
||||
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
|
||||
import { TerminalModal } from "../web-server/terminal-modal";
|
||||
import { ShowServerActions } from "./actions/show-server-actions";
|
||||
import { HandleServers } from "./handle-servers";
|
||||
@@ -42,7 +43,6 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
|
||||
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
||||
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
||||
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
|
||||
|
||||
export const ShowServers = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
@@ -112,15 +112,17 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
|
||||
toast.error("Error generating the SSH Key");
|
||||
});
|
||||
|
||||
const downloadKey = (
|
||||
content: string,
|
||||
defaultFilename: string,
|
||||
keyType: "private" | "public",
|
||||
) => {
|
||||
const downloadKey = (content: string, keyType: "private" | "public") => {
|
||||
const keyName = form.watch("name");
|
||||
const publicKey = form.watch("publicKey");
|
||||
|
||||
// Extract algorithm type from public key
|
||||
const isEd25519 = publicKey.startsWith("ssh-ed25519");
|
||||
const defaultName = isEd25519 ? "id_ed25519" : "id_rsa";
|
||||
|
||||
const filename = keyName
|
||||
? `${keyName}${sshKeyId ? `_${sshKeyId}` : ""}_${keyType}_${defaultFilename}`
|
||||
: `${keyType}_${defaultFilename}`;
|
||||
? `${keyName}${sshKeyId ? `_${sshKeyId}` : ""}_${keyType}_${defaultName}${keyType === "public" ? ".pub" : ""}`
|
||||
: `${defaultName}${keyType === "public" ? ".pub" : ""}`;
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
@@ -273,7 +275,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={() =>
|
||||
downloadKey(form.watch("privateKey"), "id_rsa", "private")
|
||||
downloadKey(form.watch("privateKey"), "private")
|
||||
}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
@@ -287,11 +289,7 @@ export const HandleSSHKeys = ({ sshKeyId }: Props) => {
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={() =>
|
||||
downloadKey(
|
||||
form.watch("publicKey"),
|
||||
"id_rsa.pub",
|
||||
"public",
|
||||
)
|
||||
downloadKey(form.watch("publicKey"), "public")
|
||||
}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
|
||||
key={sshKey.sshKeyId}
|
||||
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 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { GlobeIcon } from "lucide-react";
|
||||
@@ -33,11 +35,19 @@ import { z } from "zod";
|
||||
|
||||
const addServerDomain = z
|
||||
.object({
|
||||
domain: z.string().min(1, { message: "URL is required" }),
|
||||
domain: z.string(),
|
||||
letsEncryptEmail: z.string(),
|
||||
https: z.boolean().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.https && !data.certificateType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["certificateType"],
|
||||
message: "Required",
|
||||
});
|
||||
}
|
||||
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -61,15 +71,18 @@ export const WebDomain = () => {
|
||||
domain: "",
|
||||
certificateType: "none",
|
||||
letsEncryptEmail: "",
|
||||
https: false,
|
||||
},
|
||||
resolver: zodResolver(addServerDomain),
|
||||
});
|
||||
const https = form.watch("https");
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
domain: data?.user?.host || "",
|
||||
certificateType: data?.user?.certificateType,
|
||||
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
|
||||
https: data?.user?.https || false,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
@@ -79,6 +92,7 @@ export const WebDomain = () => {
|
||||
host: data.domain,
|
||||
letsEncryptEmail: data.letsEncryptEmail,
|
||||
certificateType: data.certificateType,
|
||||
https: data.https,
|
||||
})
|
||||
.then(async () => {
|
||||
await refetch();
|
||||
@@ -155,44 +169,67 @@ export const WebDomain = () => {
|
||||
/>
|
||||
<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>
|
||||
name="https"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm w-full col-span-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically provision SSL Certificate.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</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">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
|
||||
@@ -159,9 +159,15 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.onChange(
|
||||
value === ""
|
||||
? undefined
|
||||
: Number(value),
|
||||
);
|
||||
}}
|
||||
value={field.value || ""}
|
||||
className="w-full dark:bg-black"
|
||||
placeholder="e.g. 8080"
|
||||
/>
|
||||
@@ -185,9 +191,15 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
field.onChange(
|
||||
value === ""
|
||||
? undefined
|
||||
: Number(value),
|
||||
);
|
||||
}}
|
||||
value={field.value || ""}
|
||||
className="w-full dark:bg-black"
|
||||
placeholder="e.g. 80"
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,7 +23,6 @@ import dynamic from "next/dynamic";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "../../application/logs/show";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const DockerLogsId = dynamic(
|
||||
() =>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { DatabaseIcon, AlertTriangle } from "lucide-react";
|
||||
import { AlertTriangle, DatabaseIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
|
||||
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
|
||||
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
||||
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
|
||||
import { RebuildDatabase } from "./rebuild-database";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -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) => {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { api } from "@/utils/api";
|
||||
import type { IUpdateData } from "@dokploy/server/index";
|
||||
import { Download } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import UpdateServer from "../dashboard/settings/web-server/update-server";
|
||||
import { Button } from "../ui/button";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
||||
@@ -120,17 +120,6 @@ export const UserNav = () => {
|
||||
Docker
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{data?.role === "owner" && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push("/dashboard/settings");
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -144,17 +133,6 @@ export const UserNav = () => {
|
||||
Servers
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{data?.role === "owner" && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push("/dashboard/settings");
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
|
||||
@@ -37,7 +37,9 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
|
||||
)}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{_index + 1 < list.length && <BreadcrumbSeparator className="block" />}
|
||||
{_index + 1 < list.length && (
|
||||
<BreadcrumbSeparator className="block" />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
|
||||
@@ -3,18 +3,18 @@ import { json } from "@codemirror/lang-json";
|
||||
import { yaml } from "@codemirror/lang-yaml";
|
||||
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 { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
|
||||
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
autocompletion,
|
||||
type CompletionContext,
|
||||
type CompletionResult,
|
||||
type Completion,
|
||||
} from "@codemirror/autocomplete";
|
||||
|
||||
// Docker Compose completion options
|
||||
const dockerComposeServices = [
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
className={cn("text-left", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
value={props.value === undefined || props.value === "" ? "" : String(props.value)}
|
||||
value={props.value === undefined ? undefined : String(props.value)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.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>,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
31
apps/dokploy/drizzle/0079_bizarre_wendell_rand.sql
Normal file
31
apps/dokploy/drizzle/0079_bizarre_wendell_rand.sql
Normal 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;
|
||||
1
apps/dokploy/drizzle/0080_sleepy_sinister_six.sql
Normal file
1
apps/dokploy/drizzle/0080_sleepy_sinister_six.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" DROP COLUMN "giteaProjectId";
|
||||
1
apps/dokploy/drizzle/0081_lovely_mentallo.sql
Normal file
1
apps/dokploy/drizzle/0081_lovely_mentallo.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "gitea" DROP COLUMN "gitea_username";
|
||||
2
apps/dokploy/drizzle/0082_clean_mandarin.sql
Normal file
2
apps/dokploy/drizzle/0082_clean_mandarin.sql
Normal 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;
|
||||
1
apps/dokploy/drizzle/0083_parallel_stranger.sql
Normal file
1
apps/dokploy/drizzle/0083_parallel_stranger.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."databaseType" ADD VALUE 'web-server';
|
||||
1
apps/dokploy/drizzle/0084_thin_iron_lad.sql
Normal file
1
apps/dokploy/drizzle/0084_thin_iron_lad.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user_temp" ADD COLUMN "https" boolean DEFAULT false NOT NULL;
|
||||
5354
apps/dokploy/drizzle/meta/0079_snapshot.json
Normal file
5354
apps/dokploy/drizzle/meta/0079_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5348
apps/dokploy/drizzle/meta/0080_snapshot.json
Normal file
5348
apps/dokploy/drizzle/meta/0080_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5342
apps/dokploy/drizzle/meta/0081_snapshot.json
Normal file
5342
apps/dokploy/drizzle/meta/0081_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5361
apps/dokploy/drizzle/meta/0082_snapshot.json
Normal file
5361
apps/dokploy/drizzle/meta/0082_snapshot.json
Normal file
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
Reference in New Issue
Block a user