Merge branch 'canary' into feat/quick-service-switcher

This commit is contained in:
Mauricio Siu
2026-02-26 22:28:34 -06:00
382 changed files with 40007 additions and 5684 deletions

21
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Dockerfile for DevContainer
FROM node:24.4.0-bullseye-slim
# Install essential packages
RUN apt-get update && apt-get install -y \
curl \
bash \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Set up PNPM
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable && corepack prepare pnpm@10.22.0 --activate
# Create workspace directory
WORKDIR /workspaces/dokploy
# Set up user permissions
USER node

View File

@@ -0,0 +1,53 @@
{
"name": "Dokploy development container",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/git:1": {
"ppa": true,
"version": "latest"
},
"ghcr.io/devcontainers/features/go:1": {
"version": "1.20"
}
},
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.vscode-typescript-next",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-json",
"biomejs.biome",
"golang.go",
"redhat.vscode-xml",
"github.vscode-github-actions",
"github.copilot",
"github.copilot-chat"
]
}
},
"forwardPorts": [3000, 5432, 6379],
"portsAttributes": {
"3000": {
"label": "Dokploy App",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "silent"
},
"6379": {
"label": "Redis",
"onAutoForward": "silent"
}
},
"remoteUser": "node",
"workspaceFolder": "/workspaces/dokploy",
"runArgs": ["--name", "dokploy-devcontainer"]
}

View File

@@ -13,6 +13,17 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set tag and version
id: meta-cloud
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
echo "version=$VERSION" >> $GITHUB_OUTPUT
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -25,8 +36,7 @@ jobs:
context: . context: .
file: ./Dockerfile.cloud file: ./Dockerfile.cloud
push: true push: true
tags: | tags: ${{ steps.meta-cloud.outputs.tags }}
siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64 platforms: linux/amd64
build-args: | build-args: |
NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }} NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }}
@@ -40,6 +50,16 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set tag and version
id: meta-schedule
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -52,8 +72,7 @@ jobs:
context: . context: .
file: ./Dockerfile.schedule file: ./Dockerfile.schedule
push: true push: true
tags: | tags: ${{ steps.meta-schedule.outputs.tags }}
siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64 platforms: linux/amd64
build-and-push-server-image: build-and-push-server-image:
@@ -63,6 +82,16 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Set tag and version
id: meta-server
run: |
VERSION=$(jq -r .version apps/dokploy/package.json)
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
else
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
fi
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -75,6 +104,5 @@ jobs:
context: . context: .
file: ./Dockerfile.server file: ./Dockerfile.server
push: true push: true
tags: | tags: ${{ steps.meta-server.outputs.tags }}
siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64 platforms: linux/amd64

22
.github/workflows/pr-quality.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
max-failures: 4
blocked-commit-authors: "claude,copilot"
require-description: true
min-account-age: 5

View File

@@ -18,7 +18,7 @@ jobs:
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20.16.0 node-version: 24.4.0
cache: "pnpm" cache: "pnpm"
- name: Install Nixpacks - name: Install Nixpacks

View File

@@ -24,7 +24,7 @@ jobs:
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20.16.0 node-version: 24.4.0
cache: "pnpm" cache: "pnpm"
- name: Install dependencies - name: Install dependencies

5
.gitignore vendored
View File

@@ -43,7 +43,4 @@ yarn-error.log*
*.pem *.pem
.db .db
# Development environment
.devcontainer

2
.nvmrc
View File

@@ -1 +1 @@
20.16.0 24.4.0

View File

@@ -53,7 +53,7 @@ feat: add new feature
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch. Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory. We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory.
```bash ```bash
git clone https://github.com/dokploy/dokploy.git git clone https://github.com/dokploy/dokploy.git
@@ -165,10 +165,11 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
### Important Considerations for Pull Requests ### Important Considerations for Pull Requests
- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code. - **Testing is Mandatory:** All Pull Requests **must be tested** by the PR author before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested by their creator will be rejected.** This policy keeps the PR history clean and values contributors who submit verified, working code. Untested PRs are often recognizable by disproportionately large or scattered changes for simple tasks—please test first.
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects. - **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task. - **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`). - **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing.
Thank you for your contribution! Thank you for your contribution!

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base FROM node:24.4.0-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN corepack enable
RUN corepack prepare pnpm@9.12.0 --activate RUN corepack prepare pnpm@10.22.0 --activate
FROM base AS build FROM base AS build
COPY . /usr/src/app COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/dokploy run build RUN pnpm --filter=./apps/dokploy run build
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base FROM node:24.4.0-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN corepack enable
RUN corepack prepare pnpm@9.12.0 --activate RUN corepack prepare pnpm@10.22.0 --activate
FROM base AS build FROM base AS build
COPY . /usr/src/app COPY . /usr/src/app
@@ -29,7 +29,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/dokploy run build RUN pnpm --filter=./apps/dokploy run build
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base FROM node:24.4.0-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN corepack enable
RUN corepack prepare pnpm@9.12.0 --activate RUN corepack prepare pnpm@10.22.0 --activate
FROM base AS build FROM base AS build
COPY . /usr/src/app COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/schedules run build RUN pnpm --filter=./apps/schedules run build
RUN pnpm --filter=./apps/schedules --prod deploy /prod/schedules RUN pnpm --filter=./apps/schedules --prod deploy --legacy /prod/schedules
RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist

View File

@@ -1,9 +1,9 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
FROM node:20.16.0-slim AS base FROM node:24.4.0-slim AS base
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN corepack enable
RUN corepack prepare pnpm@9.12.0 --activate RUN corepack prepare pnpm@10.22.0 --activate
FROM base AS build FROM base AS build
COPY . /usr/src/app COPY . /usr/src/app
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/api run build RUN pnpm --filter=./apps/api run build
RUN pnpm --filter=./apps/api --prod deploy /prod/api RUN pnpm --filter=./apps/api --prod deploy --legacy /prod/api
RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist

View File

@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "PORT=4000 tsx watch src/index.ts", "dev": "PORT=4000 tsx watch src/index.ts",
"build": "tsc --project tsconfig.json", "build": "rimraf dist && tsc --project tsconfig.json",
"start": "node dist/index.js", "start": "node dist/index.js",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
@@ -12,7 +12,7 @@
"inngest": "3.40.1", "inngest": "3.40.1",
"@dokploy/server": "workspace:*", "@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3", "@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0", "@hono/zod-validator": "0.7.6",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"hono": "^4.11.7", "hono": "^4.11.7",
"pino": "9.4.0", "pino": "9.4.0",
@@ -20,18 +20,19 @@
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"redis": "4.7.0", "redis": "4.7.0",
"zod": "^3.25.32" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.16.0", "@types/node": "^24.4.0",
"@types/react": "^18.2.37", "@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15", "@types/react-dom": "^18.2.15",
"rimraf": "6.1.3",
"tsx": "^4.16.2", "tsx": "^4.16.2",
"typescript": "^5.8.3" "typescript": "^5.8.3"
}, },
"packageManager": "pnpm@9.12.0", "packageManager": "pnpm@10.22.0",
"engines": { "engines": {
"node": "^20.16.0", "node": "^24.4.0",
"pnpm": ">=9.12.0" "pnpm": ">=10.22.0"
} }
} }

View File

@@ -1 +0,0 @@
20.16.0

View File

@@ -28,6 +28,9 @@ vi.mock("@dokploy/server/db", () => {
applications: { applications: {
findFirst: vi.fn(), findFirst: vi.fn(),
}, },
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
}, },
}, },
}; };

View File

@@ -29,6 +29,9 @@ vi.mock("@dokploy/server/db", () => {
applications: { applications: {
findFirst: vi.fn(), findFirst: vi.fn(),
}, },
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
}, },
}, },
}; };

View File

@@ -83,6 +83,14 @@ describe("GitHub Webhook Skip CI", () => {
{ commits: [{ message: "[skip ci] test" }] }, { commits: [{ message: "[skip ci] test" }] },
), ),
).toBe("[skip ci] test"); ).toBe("[skip ci] test");
// Soft Serve
expect(
extractCommitMessage(
{ "x-softserve-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
}); });
it("should handle missing commit message", () => { it("should handle missing commit message", () => {
@@ -99,6 +107,9 @@ describe("GitHub Webhook Skip CI", () => {
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe( expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
"NEW COMMIT", "NEW COMMIT",
); );
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
"NEW COMMIT",
);
}); });
}); });

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
extractBranchName,
extractCommitMessage,
extractHash,
getProviderByHeader,
} from "@/pages/api/deploy/[refreshToken]";
describe("Soft Serve Webhook", () => {
const mockSoftServeHeaders = {
"x-softserve-event": "push",
};
const createMockBody = (message: string, hash: string, branch: string) => ({
event: "push",
ref: `refs/heads/${branch}`,
after: hash,
commits: [{ message: message }],
});
const message: string = "feat: add new feature";
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
const branch: string = "feat/add-new";
const goodWebhook = createMockBody(message, hash, branch);
it("should properly extract the provider name", () => {
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
});
it("should properly extract the commit message", () => {
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
message,
);
});
it("should properly extract hash", () => {
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
});
it("should properly extract branch name", () => {
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
});
it("should gracefully handle invalid webhook", () => {
expect(getProviderByHeader({})).toBeNull();
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
});
});

View File

@@ -6,6 +6,7 @@ import { paths } from "@dokploy/server/constants";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const OUTPUT_BASE = "./__test__/drop/zips/output";
const { APPLICATIONS_PATH } = paths(); const { APPLICATIONS_PATH } = paths();
vi.mock("@dokploy/server/constants", async (importOriginal) => { vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual = await importOriginal(); const actual = await importOriginal();
@@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
// @ts-ignore // @ts-ignore
...actual, ...actual,
paths: () => ({ paths: () => ({
APPLICATIONS_PATH: "./__test__/drop/zips/output", // @ts-ignore
...actual.paths(),
BASE_PATH: OUTPUT_BASE,
APPLICATIONS_PATH: OUTPUT_BASE,
}), }),
}; };
}); });
@@ -150,6 +154,176 @@ const baseApp: ApplicationNested = {
ulimitsSwarm: null, ulimitsSwarm: null,
}; };
/**
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
*/
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
baseApp.appName = "ghsa-rce";
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
const cronPayload = "* * * * * root id\n";
const placeholder = "x".repeat(traversalEntry.length);
const zip = new AdmZip();
zip.addFile(
"package.json",
Buffer.from('{"name": "app", "version": "1.0.0"}'),
);
zip.addFile("index.js", Buffer.from('console.log("Application");'));
zip.addFile(placeholder, Buffer.from(cronPayload));
let buf = Buffer.from(zip.toBuffer());
buf = Buffer.from(
buf.toString("binary").split(placeholder).join(traversalEntry),
"binary",
);
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
/Path traversal detected.*resolved path escapes output directory/,
);
});
});
describe("security: existing symlink escape", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT write outside base when directory is a symlink", async () => {
const appName = "symlink-existing";
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// outside target (attacker wants to write here)
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
await fs.mkdir(outside, { recursive: true });
// attacker-controlled symlink inside project
await fs.symlink(outside, path.join(output, "logs"));
// zip looks totally harmless
const zip = new AdmZip();
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
// if vulnerable -> file exists outside sandbox
const escaped = await fs
.readFile(path.join(outside, "pwned.txt"), "utf8")
.then(() => true)
.catch(() => false);
expect(escaped).toBe(false);
});
});
describe("security: zip symlink entry blocked", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("rejects zip containing real symlink entry", async () => {
const appName = "zip-symlink";
const zipBuffer = await fs.readFile(
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
);
const file = new File([zipBuffer as any], "exploit.zip");
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
/Dangerous node entries are not allowed/,
);
});
});
describe("unzipDrop path under output (no traversal)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
baseApp.appName = "cron-under-output";
const zip = new AdmZip();
zip.addFile(
"etc/cron.d/malicious-cron",
Buffer.from("* * * * * root id\n"),
);
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
const file = new File(
[zip.toBuffer() as unknown as ArrayBuffer],
"app.zip",
);
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
await unzipDrop(file, baseApp);
const content = await fs.readFile(
path.join(outputPath, "etc/cron.d/malicious-cron"),
"utf8",
);
expect(content).toBe("* * * * * root id\n");
});
});
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
beforeAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
afterAll(async () => {
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
});
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
const appName = "sandbox-escape";
const base = APPLICATIONS_PATH.replace("/applications", "");
const output = path.join(APPLICATIONS_PATH, appName, "code");
await fs.mkdir(output, { recursive: true });
// attacker writes into traefik config inside base
const zip = new AdmZip();
zip.addFile(
"../../../traefik/dynamic/evil.yml",
Buffer.from("pwned: true"),
);
const file = new File([zip.toBuffer() as any], "exploit.zip");
await unzipDrop(file, { ...baseApp, appName });
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
const exists = await fs
.readFile(escapedPath)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
});
describe("unzipDrop using real zip files", () => { describe("unzipDrop using real zip files", () => {
// const { APPLICATIONS_PATH } = paths(); // const { APPLICATIONS_PATH } = paths();
beforeAll(async () => { beforeAll(async () => {
@@ -166,14 +340,12 @@ describe("unzipDrop using real zip files", () => {
try { try {
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code"); const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/single-file.zip"); const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
console.log(`Output Path: ${outputPath}`);
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>; const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
const file = new File([zipBuffer], "single.zip"); const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp); await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true }); const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "test.txt")).toBe(true); expect(files.some((f) => f.name === "test.txt")).toBe(true);
} catch (err) { } catch (err) {
console.log(err);
} finally { } finally {
} }
}); });

View File

@@ -0,0 +1 @@
/etc/passwd

View File

@@ -0,0 +1,81 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
const BASE = "/base";
vi.mock("@dokploy/server/constants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@dokploy/server/constants")>();
return {
...actual,
paths: () => ({
...actual.paths(),
BASE_PATH: BASE,
LOGS_PATH: `${BASE}/logs`,
APPLICATIONS_PATH: `${BASE}/applications`,
}),
};
});
// Import after mock so paths() uses our BASE
const { readValidDirectory } = await import("@dokploy/server");
describe("readValidDirectory (path traversal)", () => {
it("returns true when directory is exactly BASE_PATH", () => {
expect(readValidDirectory(BASE)).toBe(true);
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
});
it("returns true when directory is under BASE_PATH", () => {
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
});
it("returns false for path traversal escaping base (absolute)", () => {
expect(readValidDirectory("/etc/passwd")).toBe(false);
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
expect(readValidDirectory("/tmp/outside")).toBe(false);
});
it("returns false when resolved path escapes base via ..", () => {
// Resolved: /etc/passwd (outside /base)
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
});
it("returns true when .. stays within base", () => {
// e.g. /base/logs/../applications -> /base/applications (still under /base)
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
});
it("accepts serverId for remote base path", () => {
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
expect(readValidDirectory(BASE, "server-1")).toBe(true);
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
});
it("returns false for null/undefined-like paths that resolve outside", () => {
// Paths that might resolve to cwd or root
expect(readValidDirectory(".")).toBe(false);
expect(readValidDirectory("..")).toBe(false);
});
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
expect(readValidDirectory(`${BASE}/`)).toBe(true);
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
});
it("returns false when path looks like base but is a sibling or prefix", () => {
expect(readValidDirectory("/base-evil")).toBe(false);
expect(readValidDirectory("/bas")).toBe(false);
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
});
it("returns false for empty string (resolves to cwd)", () => {
expect(readValidDirectory("")).toBe(false);
});
});

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import {
isValidContainerId,
isValidSearch,
isValidSince,
isValidTail,
} from "../../server/wss/utils";
describe("isValidTail (docker-container-logs)", () => {
it("accepts valid numeric tail values", () => {
expect(isValidTail("0")).toBe(true);
expect(isValidTail("1")).toBe(true);
expect(isValidTail("100")).toBe(true);
expect(isValidTail("10000")).toBe(true);
});
it("rejects tail above 10000", () => {
expect(isValidTail("10001")).toBe(false);
expect(isValidTail("99999")).toBe(false);
});
it("rejects non-numeric tail", () => {
expect(isValidTail("")).toBe(false);
expect(isValidTail("abc")).toBe(false);
expect(isValidTail("10a")).toBe(false);
expect(isValidTail("-1")).toBe(false);
});
it("rejects command injection payloads in tail", () => {
expect(isValidTail("10; whoami; #")).toBe(false);
expect(isValidTail("100 | cat /etc/passwd")).toBe(false);
expect(isValidTail("$(id)")).toBe(false);
expect(isValidTail("`id`")).toBe(false);
expect(isValidTail("100\nid")).toBe(false);
expect(isValidTail("100 && id")).toBe(false);
expect(isValidTail("100; env | grep DATABASE")).toBe(false);
});
});
describe("isValidSince (docker-container-logs)", () => {
it("accepts 'all'", () => {
expect(isValidSince("all")).toBe(true);
});
it("accepts valid duration format (number + s|m|h|d)", () => {
expect(isValidSince("5s")).toBe(true);
expect(isValidSince("10m")).toBe(true);
expect(isValidSince("1h")).toBe(true);
expect(isValidSince("2d")).toBe(true);
expect(isValidSince("0s")).toBe(true);
expect(isValidSince("999d")).toBe(true);
});
it("rejects invalid duration format", () => {
expect(isValidSince("")).toBe(false);
expect(isValidSince("5")).toBe(false);
expect(isValidSince("s")).toBe(false);
expect(isValidSince("5x")).toBe(false);
expect(isValidSince("5sec")).toBe(false);
expect(isValidSince("5 m")).toBe(false);
});
it("rejects command injection payloads in since", () => {
expect(isValidSince("5s; whoami")).toBe(false);
expect(isValidSince("all; id")).toBe(false);
expect(isValidSince("1m$(id)")).toBe(false);
expect(isValidSince("1m | cat /etc/passwd")).toBe(false);
});
});
describe("isValidSearch (docker-container-logs)", () => {
it("accepts empty string", () => {
expect(isValidSearch("")).toBe(true);
});
it("accepts only alphanumeric, space, dot, underscore, hyphen", () => {
expect(isValidSearch("error")).toBe(true);
expect(isValidSearch("foo bar")).toBe(true);
expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true);
expect(isValidSearch("")).toBe(true);
});
it("rejects strings longer than 500 chars", () => {
expect(isValidSearch("a".repeat(501))).toBe(false);
expect(isValidSearch("a".repeat(500))).toBe(true);
});
it("rejects control characters and non-printable", () => {
expect(isValidSearch("foo\nbar")).toBe(false);
expect(isValidSearch("foo\rbar")).toBe(false);
expect(isValidSearch("\x00")).toBe(false);
expect(isValidSearch("a\x19b")).toBe(false);
});
it("rejects command injection vectors in search (search is concatenated into shell)", () => {
// Double-quoted context (SSH line 99): $ and ` execute
expect(isValidSearch("$(whoami)")).toBe(false);
expect(isValidSearch("`id`")).toBe(false);
expect(isValidSearch("$(id)")).toBe(false);
// Single-quoted context (local line 153): ' breaks out
expect(isValidSearch("'$(whoami)'")).toBe(false);
expect(isValidSearch("error'")).toBe(false);
expect(isValidSearch("'; whoami; #")).toBe(false);
// Other shell-metacharacters
expect(isValidSearch("error; id")).toBe(false);
expect(isValidSearch("a|b")).toBe(false);
expect(isValidSearch('error"')).toBe(false);
expect(isValidSearch("a&b")).toBe(false);
});
});
describe("isValidContainerId (docker-container-logs)", () => {
it("accepts valid hex container IDs", () => {
expect(isValidContainerId("a".repeat(12))).toBe(true);
expect(isValidContainerId("abc123def456")).toBe(true);
expect(isValidContainerId("a".repeat(64))).toBe(true);
});
it("accepts valid container names", () => {
expect(isValidContainerId("my-container")).toBe(true);
expect(isValidContainerId("app_1")).toBe(true);
expect(isValidContainerId("service.name")).toBe(true);
});
it("rejects command injection in container ID", () => {
expect(isValidContainerId("dummy; whoami")).toBe(false);
expect(isValidContainerId("$(id)")).toBe(false);
expect(isValidContainerId("`id`")).toBe(false);
expect(isValidContainerId("container|cat /etc/passwd")).toBe(false);
expect(isValidContainerId("x; env | grep DATABASE")).toBe(false);
});
});

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Server } from "lucide-react"; import { Server } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -73,7 +73,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
}; };
const { mutateAsync, isLoading } = mutationMap[type] const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]() ? mutationMap[type]()
: api.mongo.update.useMutation(); : api.mongo.update.useMutation();
@@ -236,7 +236,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
)} )}
<div className="flex justify-end"> <div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit"> <Button isLoading={isPending} type="submit" className="w-fit">
Save Save
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -105,7 +105,14 @@ export const ModeForm = ({ id, type }: ModeFormProps) => {
const modeData = const modeData =
formData.type === "Replicated" formData.type === "Replicated"
? { Replicated: { Replicas: formData.Replicas } } ? {
Replicated: {
Replicas:
formData.Replicas !== undefined && formData.Replicas !== ""
? Number(formData.Replicas)
: undefined,
},
}
: { Global: {} }; : { Global: {} };
await mutateAsync({ await mutateAsync({

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Plus, Trash2 } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
@@ -50,7 +50,7 @@ export const AddCommand = ({ applicationId }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, isLoading } = api.application.update.useMutation(); const { mutateAsync, isPending } = api.application.update.useMutation();
const form = useForm<AddCommand>({ const form = useForm<AddCommand>({
defaultValues: { defaultValues: {
@@ -177,7 +177,7 @@ export const AddCommand = ({ applicationId }: Props) => {
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit"> <Button isLoading={isPending} type="submit" className="w-fit">
Save Save
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Code2, Globe2, HardDrive } from "lucide-react"; import { Code2, Globe2, HardDrive } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -69,11 +69,11 @@ export const ShowImport = ({ composeId }: Props) => {
} | null>(null); } | null>(null);
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } = const { mutateAsync: processTemplate, isPending: isLoadingTemplate } =
api.compose.processTemplate.useMutation(); api.compose.processTemplate.useMutation();
const { const {
mutateAsync: importTemplate, mutateAsync: importTemplate,
isLoading: isImporting, isPending: isImporting,
isSuccess: isImportSuccess, isSuccess: isImportSuccess,
} = api.compose.import.useMutation(); } = api.compose.import.useMutation();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react"; import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form"; import { useForm, useWatch } from "react-hook-form";
@@ -35,13 +35,9 @@ import { api } from "@/utils/api";
const AddPortSchema = z.object({ const AddPortSchema = z.object({
publishedPort: z.number().int().min(1).max(65535), publishedPort: z.number().int().min(1).max(65535),
publishMode: z.enum(["ingress", "host"], { publishMode: z.enum(["ingress", "host"]),
required_error: "Publish mode is required",
}),
targetPort: z.number().int().min(1).max(65535), targetPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"], { protocol: z.enum(["tcp", "udp"]),
required_error: "Protocol is required",
}),
}); });
type AddPort = z.infer<typeof AddPortSchema>; type AddPort = z.infer<typeof AddPortSchema>;
@@ -68,7 +64,7 @@ export const HandlePorts = ({
enabled: !!portId, enabled: !!portId,
}, },
); );
const { mutateAsync, isLoading, error, isError } = portId const { mutateAsync, isPending, error, isError } = portId
? api.port.update.useMutation() ? api.port.update.useMutation()
: api.port.create.useMutation(); : api.port.create.useMutation();
@@ -270,7 +266,7 @@ export const HandlePorts = ({
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-add-port" form="hook-form-add-port"
type="submit" type="submit"
> >

View File

@@ -25,7 +25,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const { mutateAsync: deletePort, isLoading: isRemoving } = const { mutateAsync: deletePort, isPending: isRemoving } =
api.port.delete.useMutation(); api.port.delete.useMutation();
return ( return (

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react"; import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -100,11 +100,11 @@ export const HandleRedirect = ({
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } = redirectId const { mutateAsync, isPending, error, isError } = redirectId
? api.redirects.update.useMutation() ? api.redirects.update.useMutation()
: api.redirects.create.useMutation(); : api.redirects.create.useMutation();
const form = useForm<AddRedirect>({ const form = useForm({
defaultValues: { defaultValues: {
permanent: false, permanent: false,
regex: "", regex: "",
@@ -268,7 +268,7 @@ export const HandleRedirect = ({
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-add-redirect" form="hook-form-add-redirect"
type="submit" type="submit"
> >

View File

@@ -24,7 +24,7 @@ export const ShowRedirects = ({ applicationId }: Props) => {
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const { mutateAsync: deleteRedirect, isLoading: isRemoving } = const { mutateAsync: deleteRedirect, isPending: isRemoving } =
api.redirects.delete.useMutation(); api.redirects.delete.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon, PlusIcon } from "lucide-react"; import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -46,7 +46,7 @@ export const HandleSecurity = ({
}: Props) => { }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { data } = api.security.one.useQuery( const { data, refetch } = api.security.one.useQuery(
{ {
securityId: securityId ?? "", securityId: securityId ?? "",
}, },
@@ -55,7 +55,7 @@ export const HandleSecurity = ({
}, },
); );
const { mutateAsync, isLoading, error, isError } = securityId const { mutateAsync, isPending, error, isError } = securityId
? api.security.update.useMutation() ? api.security.update.useMutation()
: api.security.create.useMutation(); : api.security.create.useMutation();
@@ -88,6 +88,7 @@ export const HandleSecurity = ({
await utils.application.readTraefikConfig.invalidate({ await utils.application.readTraefikConfig.invalidate({
applicationId, applicationId,
}); });
await refetch();
setIsOpen(false); setIsOpen(false);
}) })
.catch(() => { .catch(() => {
@@ -163,7 +164,7 @@ export const HandleSecurity = ({
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-add-security" form="hook-form-add-security"
type="submit" type="submit"
> >

View File

@@ -27,7 +27,7 @@ export const ShowSecurity = ({ applicationId }: Props) => {
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const { mutateAsync: deleteSecurity, isLoading: isRemoving } = const { mutateAsync: deleteSecurity, isPending: isRemoving } =
api.security.delete.useMutation(); api.security.delete.useMutation();
const utils = api.useUtils(); const utils = api.useUtils();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Server } from "lucide-react"; import { Server } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -74,7 +74,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
const { data: buildServers } = api.server.buildServers.useQuery(); const { data: buildServers } = api.server.buildServers.useQuery();
const { data: registries } = api.registry.all.useQuery(); const { data: registries } = api.registry.all.useQuery();
const { mutateAsync, isLoading } = api.application.update.useMutation(); const { mutateAsync, isPending } = api.application.update.useMutation();
const form = useForm<Schema>({ const form = useForm<Schema>({
defaultValues: { defaultValues: {
@@ -274,7 +274,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
/> />
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isPending} type="submit">
Save Save
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { InfoIcon, Plus, Trash2 } from "lucide-react"; import { InfoIcon, Plus, Trash2 } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
@@ -128,11 +128,11 @@ export const ShowResources = ({ id, type }: Props) => {
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
}; };
const { mutateAsync, isLoading } = mutationMap[type] const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]() ? mutationMap[type]()
: api.mongo.update.useMutation(); : api.mongo.update.useMutation();
const form = useForm<AddResources>({ const form = useForm({
defaultValues: { defaultValues: {
cpuLimit: "", cpuLimit: "",
cpuReservation: "", cpuReservation: "",
@@ -452,6 +452,11 @@ export const ShowResources = ({ id, type }: Props) => {
min={-1} min={-1}
placeholder="65535" placeholder="65535"
{...field} {...field}
value={
typeof field.value === "number"
? field.value
: ""
}
onChange={(e) => onChange={(e) =>
field.onChange(Number(e.target.value)) field.onChange(Number(e.target.value))
} }
@@ -475,6 +480,11 @@ export const ShowResources = ({ id, type }: Props) => {
min={-1} min={-1}
placeholder="65535" placeholder="65535"
{...field} {...field}
value={
typeof field.value === "number"
? field.value
: ""
}
onChange={(e) => onChange={(e) =>
field.onChange(Number(e.target.value)) field.onChange(Number(e.target.value))
} }
@@ -507,7 +517,7 @@ export const ShowResources = ({ id, type }: Props) => {
</div> </div>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isPending} type="submit">
Save Save
</Button> </Button>
</div> </div>

View File

@@ -15,7 +15,7 @@ interface Props {
} }
export const ShowTraefikConfig = ({ applicationId }: Props) => { export const ShowTraefikConfig = ({ applicationId }: Props) => {
const { data, isLoading } = api.application.readTraefikConfig.useQuery( const { data, isPending } = api.application.readTraefikConfig.useQuery(
{ {
applicationId, applicationId,
}, },
@@ -35,7 +35,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
{isLoading ? ( {isPending ? (
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]"> <span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
Loading... Loading...
<Loader2 className="animate-spin" /> <Loader2 className="animate-spin" />

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -69,7 +69,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const { mutateAsync, isLoading, error, isError } = const { mutateAsync, isPending, error, isError } =
api.application.updateTraefikConfig.useMutation(); api.application.updateTraefikConfig.useMutation();
const form = useForm<UpdateTraefikConfig>({ const form = useForm<UpdateTraefikConfig>({
@@ -126,7 +126,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
}} }}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button isLoading={isLoading}>Modify</Button> <Button isLoading={isPending}>Modify</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-4xl"> <DialogContent className="sm:max-w-4xl">
<DialogHeader> <DialogHeader>
@@ -198,7 +198,7 @@ routers:
</p> </p>
</div> </div>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-update-traefik-config" form="hook-form-update-traefik-config"
type="submit" type="submit"
> >

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import type React from "react"; import type React from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";

View File

@@ -37,7 +37,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
const { data, refetch } = queryMap[type] const { data, refetch } = queryMap[type]
? queryMap[type]() ? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const { mutateAsync: deleteVolume, isLoading: isRemoving } = const { mutateAsync: deleteVolume, isPending: isRemoving } =
api.mounts.remove.useMutation(); api.mounts.remove.useMutation();
return ( return (
<Card className="bg-background"> <Card className="bg-background">

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react"; import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -93,7 +93,7 @@ export const UpdateVolume = ({
}, },
); );
const { mutateAsync, isLoading, error, isError } = const { mutateAsync, isPending, error, isError } =
api.mounts.update.useMutation(); api.mounts.update.useMutation();
const form = useForm<UpdateMount>({ const form = useForm<UpdateMount>({
@@ -187,7 +187,7 @@ export const UpdateVolume = ({
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-blue-500/10 " className="group hover:bg-blue-500/10 "
isLoading={isLoading} isLoading={isPending}
> >
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" /> <PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button> </Button>
@@ -310,7 +310,7 @@ PORT=3000
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isPending}
// form="hook-form-update-volume" // form="hook-form-update-volume"
type="submit" type="submit"
> >

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Cog } from "lucide-react"; import { Cog } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -74,12 +74,7 @@ const buildTypeDisplayMap: Record<BuildType, string> = {
const mySchema = z.discriminatedUnion("buildType", [ const mySchema = z.discriminatedUnion("buildType", [
z.object({ z.object({
buildType: z.literal(BuildType.dockerfile), buildType: z.literal(BuildType.dockerfile),
dockerfile: z dockerfile: z.string().nullable().default(""),
.string({
required_error: "Dockerfile path is required",
invalid_type_error: "Dockerfile path is required",
})
.min(1, "Dockerfile required"),
dockerContextPath: z.string().nullable().default(""), dockerContextPath: z.string().nullable().default(""),
dockerBuildStage: z.string().nullable().default(""), dockerBuildStage: z.string().nullable().default(""),
}), }),
@@ -168,14 +163,14 @@ const resetData = (data: ApplicationData): AddTemplate => {
}; };
export const ShowBuildChooseForm = ({ applicationId }: Props) => { export const ShowBuildChooseForm = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
api.application.saveBuildType.useMutation(); api.application.saveBuildType.useMutation();
const { data, refetch } = api.application.one.useQuery( const { data, refetch } = api.application.one.useQuery(
{ applicationId }, { applicationId },
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const form = useForm<AddTemplate>({ const form = useForm({
defaultValues: { defaultValues: {
buildType: BuildType.nixpacks, buildType: BuildType.nixpacks,
}, },
@@ -347,7 +342,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
<FormLabel>Docker File</FormLabel> <FormLabel>Docker File</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="Path of your docker file" placeholder="Path of your docker file (default: Dockerfile)"
{...field} {...field}
value={field.value ?? ""} value={field.value ?? ""}
/> />
@@ -533,7 +528,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
</> </>
)} )}
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isPending} type="submit">
Save Save
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { Paintbrush } from "lucide-react"; import { Ban } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
AlertDialog, AlertDialog,
@@ -20,7 +20,7 @@ interface Props {
} }
export const CancelQueues = ({ id, type }: Props) => { export const CancelQueues = ({ id, type }: Props) => {
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
type === "application" type === "application"
? api.application.cleanQueues.useMutation() ? api.application.cleanQueues.useMutation()
: api.compose.cleanQueues.useMutation(); : api.compose.cleanQueues.useMutation();
@@ -33,9 +33,9 @@ export const CancelQueues = ({ id, type }: Props) => {
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit" isLoading={isLoading}> <Button variant="destructive" className="w-fit" isLoading={isPending}>
Cancel Queues Cancel Queues
<Paintbrush className="size-4" /> <Ban className="size-4" />
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>

View File

@@ -0,0 +1,73 @@
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
interface Props {
id: string;
type: "application" | "compose";
}
export const ClearDeployments = ({ id, type }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isPending } =
type === "application"
? api.application.clearDeployments.useMutation()
: api.compose.clearDeployments.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isPending}>
Clear deployments
<Paintbrush className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to clear old deployments?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all old deployment records and logs, keeping only
the active deployment (the most recent successful one).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
})
.then(async () => {
toast.success("Old deployments cleared successfully");
await utils.deployment.allByType.invalidate({
id,
type: type as "application" | "compose",
});
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -20,7 +20,7 @@ interface Props {
} }
export const KillBuild = ({ id, type }: Props) => { export const KillBuild = ({ id, type }: Props) => {
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
type === "application" type === "application"
? api.application.killBuild.useMutation() ? api.application.killBuild.useMutation()
: api.compose.killBuild.useMutation(); : api.compose.killBuild.useMutation();
@@ -28,7 +28,7 @@ export const KillBuild = ({ id, type }: Props) => {
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}> <Button variant="outline" className="w-fit" isLoading={isPending}>
Kill Build Kill Build
<Scissors className="size-4" /> <Scissors className="size-4" />
</Button> </Button>

View File

@@ -6,6 +6,7 @@ import {
RefreshCcw, RefreshCcw,
RocketIcon, RocketIcon,
Settings, Settings,
Trash2,
} from "lucide-react"; } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -25,6 +26,7 @@ import {
import { api, type RouterOutputs } from "@/utils/api"; import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings"; import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues"; import { CancelQueues } from "./cancel-queues";
import { ClearDeployments } from "./clear-deployments";
import { KillBuild } from "./kill-build"; import { KillBuild } from "./kill-build";
import { RefreshToken } from "./refresh-token"; import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment"; import { ShowDeployment } from "./show-deployment";
@@ -59,7 +61,7 @@ export const ShowDeployments = ({
const [activeLog, setActiveLog] = useState< const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null RouterOutputs["deployment"]["all"][number] | null
>(null); >(null);
const { data: deployments, isLoading: isLoadingDeployments } = const { data: deployments, isPending: isLoadingDeployments } =
api.deployment.allByType.useQuery( api.deployment.allByType.useQuery(
{ {
id, id,
@@ -73,19 +75,21 @@ export const ShowDeployments = ({
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync: rollback, isLoading: isRollingBack } = const { mutateAsync: rollback, isPending: isRollingBack } =
api.rollback.rollback.useMutation(); api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } = const { mutateAsync: killProcess, isPending: isKillingProcess } =
api.deployment.killProcess.useMutation(); api.deployment.killProcess.useMutation();
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
api.deployment.removeDeployment.useMutation();
// Cancel deployment mutations // Cancel deployment mutations
const { const {
mutateAsync: cancelApplicationDeployment, mutateAsync: cancelApplicationDeployment,
isLoading: isCancellingApp, isPending: isCancellingApp,
} = api.application.cancelDeployment.useMutation(); } = api.application.cancelDeployment.useMutation();
const { const {
mutateAsync: cancelComposeDeployment, mutateAsync: cancelComposeDeployment,
isLoading: isCancellingCompose, isPending: isCancellingCompose,
} = api.compose.cancelDeployment.useMutation(); } = api.compose.cancelDeployment.useMutation();
const [url, setUrl] = React.useState(""); const [url, setUrl] = React.useState("");
@@ -144,6 +148,9 @@ export const ShowDeployments = ({
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-row items-center flex-wrap gap-2"> <div className="flex flex-row items-center flex-wrap gap-2">
{(type === "application" || type === "compose") && (
<ClearDeployments id={id} type={type} />
)}
{(type === "application" || type === "compose") && ( {(type === "application" || type === "compose") && (
<KillBuild id={id} type={type} /> <KillBuild id={id} type={type} />
)} )}
@@ -252,6 +259,8 @@ export const ShowDeployments = ({
const isExpanded = expandedDescriptions.has( const isExpanded = expandedDescriptions.has(
deployment.deploymentId, deployment.deploymentId,
); );
const canDelete =
deployment.status === "done" || deployment.status === "error";
return ( return (
<div <div
@@ -370,6 +379,33 @@ export const ShowDeployments = ({
View View
</Button> </Button>
{canDelete && (
<DialogAction
title="Delete Deployment"
description="Are you sure you want to delete this deployment? This action cannot be undone."
type="default"
onClick={async () => {
try {
await removeDeployment({
deploymentId: deployment.deploymentId,
});
toast.success("Deployment deleted successfully");
} catch (error) {
toast.error("Error deleting deployment");
}
}}
>
<Button
variant="destructive"
size="sm"
isLoading={isRemovingDeployment}
>
Delete
<Trash2 className="size-4" />
</Button>
</DialogAction>
)}
{deployment?.rollback && {deployment?.rollback &&
deployment.status === "done" && deployment.status === "done" &&
type === "application" && ( type === "application" && (

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react"; import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -159,11 +159,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}, },
); );
const { mutateAsync, isError, error, isLoading } = domainId const { mutateAsync, isError, error, isPending } = domainId
? api.domain.update.useMutation() ? api.domain.update.useMutation()
: api.domain.create.useMutation(); : api.domain.create.useMutation();
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
api.domain.generateDomain.useMutation(); api.domain.generateDomain.useMutation();
const { data: canGenerateTraefikMeDomains } = const { data: canGenerateTraefikMeDomains } =
@@ -240,7 +240,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
domainType: type, domainType: type,
}); });
} }
}, [form, data, isLoading, domainId]); }, [form, data, isPending, domainId]);
// Separate effect for handling custom cert resolver validation // Separate effect for handling custom cert resolver validation
useEffect(() => { useEffect(() => {
@@ -730,7 +730,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</form> </form>
<DialogFooter> <DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit"> <Button isLoading={isPending} form="hook-form" type="submit">
{dictionary.submit} {dictionary.submit}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -97,7 +97,7 @@ export const ShowDomains = ({ id, type }: Props) => {
const { mutateAsync: validateDomain } = const { mutateAsync: validateDomain } =
api.domain.validateDomain.useMutation(); api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isLoading: isRemoving } = const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation(); api.domain.delete.useMutation();
const handleValidateDomain = async (host: string) => { const handleValidateDomain = async (host: string) => {

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { EyeIcon, EyeOffIcon } from "lucide-react"; import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type CSSProperties, useEffect, useState } from "react"; import { type CSSProperties, useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -60,7 +60,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
mongo: () => api.mongo.update.useMutation(), mongo: () => api.mongo.update.useMutation(),
compose: () => api.compose.update.useMutation(), compose: () => api.compose.update.useMutation(),
}; };
const { mutateAsync, isLoading } = mutationMap[type] const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]() ? mutationMap[type]()
: api.mongo.update.useMutation(); : api.mongo.update.useMutation();
@@ -111,7 +111,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }
@@ -121,7 +121,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
}, [form, onSubmit, isLoading]); }, [form, onSubmit, isPending]);
return ( return (
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
@@ -196,7 +196,7 @@ PORT=3000
</Button> </Button>
)} )}
<Button <Button
isLoading={isLoading} isLoading={isPending}
className="w-fit" className="w-fit"
type="submit" type="submit"
disabled={!hasChanges} disabled={!hasChanges}

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -31,7 +31,7 @@ interface Props {
} }
export const ShowEnvironment = ({ applicationId }: Props) => { export const ShowEnvironment = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
api.application.saveEnvironment.useMutation(); api.application.saveEnvironment.useMutation();
const { data, refetch } = api.application.one.useQuery( const { data, refetch } = api.application.one.useQuery(
@@ -104,7 +104,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }
@@ -114,7 +114,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
}, [form, onSubmit, isLoading]); }, [form, onSubmit, isPending]);
return ( return (
<Card className="bg-background px-6 pb-6"> <Card className="bg-background px-6 pb-6">
@@ -214,7 +214,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
</Button> </Button>
)} )}
<Button <Button
isLoading={isLoading} isLoading={isPending}
className="w-fit" className="w-fit"
type="submit" type="submit"
disabled={!hasChanges} disabled={!hasChanges}

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -74,10 +74,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
api.bitbucket.bitbucketProviders.useQuery(); api.bitbucket.bitbucketProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingBitbucketProvider } = const { mutateAsync, isPending: isSavingBitbucketProvider } =
api.application.saveBitbucketProvider.useMutation(); api.application.saveBitbucketProvider.useMutation();
const form = useForm<BitbucketProvider>({ const form = useForm({
defaultValues: { defaultValues: {
buildPath: "/", buildPath: "/",
repository: { repository: {
@@ -333,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -350,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { TrashIcon } from "lucide-react"; import { TrashIcon } from "lucide-react";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -24,10 +24,10 @@ interface Props {
export const SaveDragNDrop = ({ applicationId }: Props) => { export const SaveDragNDrop = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
api.application.dropDeployment.useMutation(); api.application.dropDeployment.useMutation();
const form = useForm<UploadFile>({ const form = useForm({
defaultValues: {}, defaultValues: {},
resolver: zodResolver(uploadFileSchema), resolver: zodResolver(uploadFileSchema),
}); });
@@ -129,8 +129,8 @@ export const SaveDragNDrop = ({ applicationId }: Props) => {
<Button <Button
type="submit" type="submit"
className="w-fit" className="w-fit"
isLoading={isLoading} isLoading={isPending}
disabled={!zip || isLoading} disabled={!zip || isPending}
> >
Deploy{" "} Deploy{" "}
</Button> </Button>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -58,10 +58,10 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
const { data: sshKeys } = api.sshKey.all.useQuery(); const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter(); const router = useRouter();
const { mutateAsync, isLoading } = const { mutateAsync, isPending } =
api.application.saveGitProvider.useMutation(); api.application.saveGitProvider.useMutation();
const form = useForm<GitProvider>({ const form = useForm({
defaultValues: { defaultValues: {
branch: "", branch: "",
buildPath: "/", buildPath: "/",
@@ -317,7 +317,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</div> </div>
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}> <Button type="submit" className="w-fit" isLoading={isPending}>
Save Save
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -88,10 +88,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery(); const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGiteaProvider } = const { mutateAsync, isPending: isSavingGiteaProvider } =
api.application.saveGiteaProvider.useMutation(); api.application.saveGiteaProvider.useMutation();
const form = useForm<GiteaProvider>({ const form = useForm({
defaultValues: { defaultValues: {
buildPath: "/", buildPath: "/",
repository: { repository: {
@@ -353,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -371,7 +371,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>
@@ -463,7 +463,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<X <X
className="size-3 cursor-pointer hover:text-destructive" className="size-3 cursor-pointer hover:text-destructive"
onClick={() => { onClick={() => {
const newPaths = [...field.value]; const newPaths = [...(field.value || [])];
newPaths.splice(index, 1); newPaths.splice(index, 1);
field.onChange(newPaths); field.onChange(newPaths);
}} }}
@@ -481,7 +481,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
const input = e.currentTarget; const input = e.currentTarget;
const path = input.value.trim(); const path = input.value.trim();
if (path) { if (path) {
field.onChange([...field.value, path]); field.onChange([...(field.value || []), path]);
input.value = ""; input.value = "";
} }
} }
@@ -498,7 +498,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
) as HTMLInputElement; ) as HTMLInputElement;
const path = input.value.trim(); const path = input.value.trim();
if (path) { if (path) {
field.onChange([...field.value, path]); field.onChange([...(field.value || []), path]);
input.value = ""; input.value = "";
} }
}} }}

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -72,10 +72,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const { data: githubProviders } = api.github.githubProviders.useQuery(); const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGithubProvider } = const { mutateAsync, isPending: isSavingGithubProvider } =
api.application.saveGithubProvider.useMutation(); api.application.saveGithubProvider.useMutation();
const form = useForm<GithubProvider>({ const form = useForm({
defaultValues: { defaultValues: {
buildPath: "/", buildPath: "/",
repository: { repository: {
@@ -94,7 +94,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const githubId = form.watch("githubId"); const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType"); const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } = const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery( api.github.getGithubRepositories.useQuery(
{ {
githubId, githubId,
@@ -320,7 +320,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -337,7 +337,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
@@ -74,10 +74,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
const { mutateAsync, isLoading: isSavingGitlabProvider } = const { mutateAsync, isPending: isSavingGitlabProvider } =
api.application.saveGitlabProvider.useMutation(); api.application.saveGitlabProvider.useMutation();
const form = useForm<GitlabProvider>({ const form = useForm({
defaultValues: { defaultValues: {
buildPath: "/", buildPath: "/",
repository: { repository: {
@@ -351,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -368,7 +368,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>

View File

@@ -36,13 +36,13 @@ interface Props {
} }
export const ShowProviderForm = ({ applicationId }: Props) => { export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: githubProviders, isLoading: isLoadingGithub } = const { data: githubProviders, isPending: isLoadingGithub } =
api.github.githubProviders.useQuery(); api.github.githubProviders.useQuery();
const { data: gitlabProviders, isLoading: isLoadingGitlab } = const { data: gitlabProviders, isPending: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery(); api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } = const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
api.bitbucket.bitbucketProviders.useQuery(); api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders, isLoading: isLoadingGitea } = const { data: giteaProviders, isPending: isLoadingGitea } =
api.gitea.giteaProviders.useQuery(); api.gitea.giteaProviders.useQuery();
const { data: application, refetch } = api.application.one.useQuery({ const { data: application, refetch } = api.application.one.useQuery({

View File

@@ -37,14 +37,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
{ enabled: !!applicationId }, { enabled: !!applicationId },
); );
const { mutateAsync: update } = api.application.update.useMutation(); const { mutateAsync: update } = api.application.update.useMutation();
const { mutateAsync: start, isLoading: isStarting } = const { mutateAsync: start, isPending: isStarting } =
api.application.start.useMutation(); api.application.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } = const { mutateAsync: stop, isPending: isStopping } =
api.application.stop.useMutation(); api.application.stop.useMutation();
const { mutateAsync: deploy } = api.application.deploy.useMutation(); const { mutateAsync: deploy } = api.application.deploy.useMutation();
const { mutateAsync: reload, isLoading: isReloading } = const { mutateAsync: reload, isPending: isReloading } =
api.application.reload.useMutation(); api.application.reload.useMutation();
const { mutateAsync: redeploy } = api.application.redeploy.useMutation(); const { mutateAsync: redeploy } = api.application.redeploy.useMutation();

View File

@@ -56,7 +56,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
const [containerId, setContainerId] = useState<string | undefined>(); const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native"); const [option, setOption] = useState<"swarm" | "native">("native");
const { data: services, isLoading: servicesLoading } = const { data: services, isPending: servicesLoading } =
api.docker.getServiceContainersByAppName.useQuery( api.docker.getServiceContainersByAppName.useQuery(
{ {
appName, appName,
@@ -67,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
}, },
); );
const { data: containers, isLoading: containersLoading } = const { data: containers, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery( api.docker.getContainersByAppNameMatch.useQuery(
{ {
appName, appName,

View File

@@ -0,0 +1,107 @@
import { FilePlus } from "lucide-react";
import { useState } from "react";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface Props {
folderPath: string;
onCreate: (filename: string, content: string) => void;
onOpenChange: (open: boolean) => void;
alwaysVisible?: boolean;
}
export const CreateFileDialog = ({
folderPath,
onCreate,
onOpenChange,
alwaysVisible = false,
}: Props) => {
const [filename, setFilename] = useState("");
const [content, setContent] = useState("");
const handleCreate = () => {
if (!filename.trim()) return;
onCreate(filename.trim(), content);
setFilename("");
setContent("");
onOpenChange(false);
};
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
type="button"
className={`h-6 w-6 ${alwaysVisible ? "" : "opacity-0 group-hover:opacity-100"}`}
title="Create file"
>
<FilePlus className="h-3 w-3" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<form
onSubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<DialogHeader>
<DialogTitle>Create file</DialogTitle>
<DialogDescription>
{folderPath ? `New file in ${folderPath}/` : "New file in root"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="filename">Filename</Label>
<Input
id="filename"
placeholder="e.g. .env.example"
value={filename}
onChange={(e) => setFilename(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Content</Label>
<div className="h-[200px] rounded-md border">
<CodeEditor
value={content}
onChange={(v) => setContent(v ?? "")}
className="h-full"
wrapperClassName="h-[200px]"
lineWrapping
/>
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" type="button">
Cancel
</Button>
</DialogClose>
<DialogClose asChild>
<Button type="submit" disabled={!filename.trim()}>
Create
</Button>
</DialogClose>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,102 @@
import { Loader2, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
interface Props {
patchId: string;
entityId: string;
type: "application" | "compose";
onSuccess?: () => void;
}
export const EditPatchDialog = ({
patchId,
entityId,
type,
onSuccess,
}: Props) => {
const { data: patch, isPending: isPatchLoading } = api.patch.one.useQuery(
{ patchId },
{ enabled: !!patchId },
);
const [content, setContent] = useState("");
useEffect(() => {
if (patch) {
setContent(patch.content);
}
}, [patch]);
const utils = api.useUtils();
const updatePatch = api.patch.update.useMutation();
const handleSave = () => {
updatePatch
.mutateAsync({ patchId, content })
.then(() => {
toast.success("Patch saved");
utils.patch.byEntityId.invalidate({ id: entityId, type });
onSuccess?.();
})
.catch((err) => {
toast.error(err.message);
});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" title="Edit patch">
<Pencil className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle>Edit Patch</DialogTitle>
<DialogDescription>
{patch ? `Editing: ${patch.filePath}` : "Loading patch..."}
</DialogDescription>
</DialogHeader>
{isPatchLoading ? (
<div className="flex flex-1 items-center justify-center px-6 py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex-1 min-h-0 px-6 overflow-hidden flex flex-col">
<CodeEditor
value={content}
onChange={(value) => setContent(value ?? "")}
className="h-[400px] w-full"
wrapperClassName="h-[400px]"
lineWrapping
/>
</div>
)}
<DialogFooter className="px-6 ">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button onClick={handleSave} isLoading={updatePatch.isPending}>
{updatePatch.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./show-patches";
export * from "./patch-editor";

View File

@@ -0,0 +1,368 @@
import {
ArrowLeft,
ChevronRight,
File,
Folder,
Loader2,
Save,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { api } from "@/utils/api";
import { CreateFileDialog } from "./create-file-dialog";
interface Props {
id: string;
type: "application" | "compose";
repoPath: string;
onClose: () => void;
}
type DirectoryEntry = {
name: string;
path: string;
type: "file" | "directory";
children?: DirectoryEntry[];
};
export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState<string>("");
const [createFolderPath, setCreateFolderPath] = useState<string | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(),
);
const utils = api.useUtils();
const { data: directories, isPending: isDirLoading } =
api.patch.readRepoDirectories.useQuery(
{ id: id, type, repoPath },
{ enabled: !!repoPath },
);
const { data: patches } = api.patch.byEntityId.useQuery(
{ id, type },
{ enabled: !!id },
);
const { mutateAsync: saveAsPatch, isPending: isSavingPatch } =
api.patch.saveFileAsPatch.useMutation();
const { mutateAsync: markForDeletion, isPending: isMarkingDeletion } =
api.patch.markFileForDeletion.useMutation();
const updatePatch = api.patch.update.useMutation();
const { data: fileData, isFetching: isFileLoading } =
api.patch.readRepoFile.useQuery(
{
id,
type,
filePath: selectedFile || "",
},
{
enabled: !!selectedFile,
},
);
useEffect(() => {
if (fileData !== undefined) {
setFileContent(fileData);
}
}, [fileData]);
const handleFileSelect = (filePath: string) => {
setSelectedFile(filePath);
};
const toggleFolder = (path: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
};
const handleSave = () => {
if (!selectedFile) return;
saveAsPatch({
id,
type,
filePath: selectedFile,
content: fileContent,
patchType: "update",
})
.then(() => {
toast.success("Patch saved");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to save patch");
});
};
const handleMarkForDeletion = () => {
if (!selectedFile) return;
markForDeletion({ id, type, filePath: selectedFile })
.then(() => {
toast.success("File marked for deletion");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to mark file for deletion");
});
};
const handleCreateFile = useCallback(
(folderPath: string, filename: string, content: string) => {
const filePath = folderPath ? `${folderPath}/${filename}` : filename;
saveAsPatch({
id,
type,
filePath,
content,
patchType: "create",
})
.then(() => {
toast.success("File created");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to create file");
});
},
[id, type, saveAsPatch, utils],
);
const selectedFilePatch = patches?.find(
(p) => p.filePath === selectedFile && p.type === "delete",
);
const handleUnmarkDeletion = () => {
if (!selectedFilePatch) return;
updatePatch
.mutateAsync({
patchId: selectedFilePatch.patchId,
type: "update",
content: fileData || "",
})
.then(() => {
toast.success("Deletion unmarked");
utils.patch.byEntityId.invalidate({ id, type });
})
.catch(() => {
toast.error("Failed to unmark deletion");
});
};
const hasChanges = fileData !== undefined && fileContent !== fileData;
const renderTree = useCallback(
(entries: DirectoryEntry[], depth = 0) => {
return entries
.sort((a, b) => {
// Directories first, then alphabetically
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
})
.map((entry) => {
const isExpanded = expandedFolders.has(entry.path);
const isSelected = selectedFile === entry.path;
if (entry.type === "directory") {
return (
<div key={entry.path}>
<div className="group flex items-center">
<button
type="button"
onClick={() => toggleFolder(entry.path)}
className={
"flex-1 flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors text-left min-w-0"
}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
<ChevronRight
className={`h-4 w-4 shrink-0 transition-transform ${
isExpanded ? "rotate-90" : ""
}`}
/>
<Folder className="h-4 w-4 shrink-0 text-blue-500" />
<span className="truncate">{entry.name}</span>
</button>
<CreateFileDialog
folderPath={entry.path}
onCreate={(filename, content) =>
handleCreateFile(entry.path, filename, content)
}
onOpenChange={(open) =>
setCreateFolderPath(open ? entry.path : null)
}
/>
</div>
{isExpanded && entry.children && (
<div>{renderTree(entry.children, depth + 1)}</div>
)}
</div>
);
}
const isMarkedForDeletion = patches?.some(
(p) => p.filePath === entry.path && p.type === "delete",
);
return (
<button
type="button"
key={entry.path}
onClick={() => handleFileSelect(entry.path)}
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${
isSelected ? "bg-muted" : ""
} ${isMarkedForDeletion ? "text-destructive" : ""}`}
style={{ paddingLeft: `${depth * 12 + 28}px` }}
>
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate">{entry.name}</span>
{isMarkedForDeletion && (
<Trash2 className="h-3 w-3 shrink-0 text-destructive ml-auto" />
)}
</button>
);
});
},
[expandedFolders, selectedFile, patches, handleCreateFile],
);
return (
<Card className="bg-background overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={onClose}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<CardTitle>Edit File</CardTitle>
<CardDescription>
{selectedFile
? `Editing: ${selectedFile}`
: "Select a file from the tree to edit"}
</CardDescription>
</div>
</div>
{selectedFile && (
<div className="flex items-center gap-2">
{selectedFilePatch ? (
<Button
variant="outline"
size="sm"
onClick={handleUnmarkDeletion}
disabled={updatePatch.isPending}
>
{updatePatch.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Unmark deletion
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={handleMarkForDeletion}
disabled={isMarkingDeletion}
>
{isMarkingDeletion && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Trash2 className="mr-2 h-4 w-4" />
Mark for deletion
</Button>
<Button
onClick={handleSave}
disabled={isSavingPatch || !hasChanges}
>
{isSavingPatch && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
Save Patch
</Button>
</>
)}
</div>
)}
</CardHeader>
<CardContent className="p-0">
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
<div className="border-r h-full overflow-hidden">
<ScrollArea className="h-full">
<div className="p-2 space-y-1">
<div className="group flex items-center gap-2 px-2 py-1.5 mb-1">
<CreateFileDialog
folderPath=""
alwaysVisible
onCreate={(filename, content) =>
handleCreateFile("", filename, content)
}
onOpenChange={(open) =>
setCreateFolderPath(open ? "" : null)
}
/>
<span className="text-xs text-muted-foreground">
New file in root
</span>
</div>
{isDirLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : directories ? (
renderTree(directories)
) : (
<div className="text-sm text-muted-foreground p-4">
No files found
</div>
)}
</div>
</ScrollArea>
</div>
<div className="h-full overflow-hidden relative">
{isFileLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : selectedFile ? (
<CodeEditor
value={fileData || ""}
onChange={(value) => setFileContent(value || "")}
className="h-full w-full"
wrapperClassName="h-full"
lineWrapping
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
Select a file to edit
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,225 @@
import { File, FilePlus2, Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import { EditPatchDialog } from "./edit-patch-dialog";
import { PatchEditor } from "./patch-editor";
interface Props {
id: string;
type: "application" | "compose";
}
export const ShowPatches = ({ id, type }: Props) => {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [repoPath, setRepoPath] = useState<string | null>(null);
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
const utils = api.useUtils();
const { data: patches, isPending: isPatchesLoading } =
api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id });
const mutationMap = {
application: () => api.patch.delete.useMutation(),
compose: () => api.patch.delete.useMutation(),
};
const ensureRepo = api.patch.ensureRepo.useMutation();
const togglePatch = api.patch.toggleEnabled.useMutation();
const { mutateAsync } = mutationMap[type]
? mutationMap[type]()
: api.patch.delete.useMutation();
const handleCloseEditor = () => {
setSelectedFile(null);
setRepoPath(null);
};
if (repoPath) {
return (
<PatchEditor
id={id}
type={type}
repoPath={repoPath || ""}
onClose={handleCloseEditor}
/>
);
}
const handleOpenEditor = async () => {
setIsLoadingRepo(true);
await ensureRepo
.mutateAsync({ id, type })
.then((result) => {
setRepoPath(result);
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setIsLoadingRepo(false);
});
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Patches</CardTitle>
<CardDescription>
Apply code patches to your repository during build. Patches are
applied after cloning the repository and before building.
</CardDescription>
</div>
{patches && patches?.length > 0 && (
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<FilePlus2 className="mr-2 h-4 w-4" />
Create Patch
</Button>
)}
</CardHeader>
<CardContent>
{isPatchesLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : patches?.length === 0 ? (
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<FilePlus2 className="h-10 w-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No patches yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Add file patches to modify your repo before each buildconfigs,
env, or code. Create your first patch to get started.
</p>
</div>
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
{isLoadingRepo && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<FilePlus2 className="mr-2 h-4 w-4" />
Create Patch
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>File Path</TableHead>
<TableHead className="w-[80px]">Type</TableHead>
<TableHead className="w-[100px]">Enabled</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{patches?.map((patch) => (
<TableRow key={patch.patchId}>
<TableCell className="font-mono text-sm">
<div className="flex items-center gap-2">
<File className="h-4 w-4 text-muted-foreground shrink-0" />
{patch.filePath}
</div>
</TableCell>
<TableCell>
<Badge
variant={
patch.type === "delete"
? "destructive"
: patch.type === "create"
? "default"
: "secondary"
}
className="font-normal"
>
{patch.type}
</Badge>
</TableCell>
<TableCell>
<Switch
checked={patch.enabled}
onCheckedChange={(checked) => {
togglePatch
.mutateAsync({
patchId: patch.patchId,
enabled: checked,
})
.then(() => {
toast.success("Patch updated");
utils.patch.byEntityId.invalidate({
id,
type,
});
})
.catch((err) => {
toast.error(err.message);
})
.finally(() => {
setIsLoadingRepo(false);
});
}}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{(patch.type === "update" || patch.type === "create") && (
<EditPatchDialog
patchId={patch.patchId}
entityId={id}
type={type}
/>
)}
<Button
variant="ghost"
size="icon"
onClick={() => {
mutateAsync({ patchId: patch.patchId })
.then(() => {
toast.success("Patch deleted");
utils.patch.byEntityId.invalidate({
id,
type,
});
})
.catch((err) => {
toast.error(err.message);
});
}}
title="Delete patch"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Dices } from "lucide-react"; import { Dices } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -75,11 +75,11 @@ export const AddPreviewDomain = ({
}, },
); );
const { mutateAsync, isError, error, isLoading } = domainId const { mutateAsync, isError, error, isPending } = domainId
? api.domain.update.useMutation() ? api.domain.update.useMutation()
: api.domain.create.useMutation(); : api.domain.create.useMutation();
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
api.domain.generateDomain.useMutation(); api.domain.generateDomain.useMutation();
const form = useForm<Domain>({ const form = useForm<Domain>({
@@ -103,7 +103,7 @@ export const AddPreviewDomain = ({
if (!domainId) { if (!domainId) {
form.reset({}); form.reset({});
} }
}, [form, form.reset, data, isLoading]); }, [form, form.reset, data, isPending]);
const dictionary = { const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created", success: domainId ? "Domain Updated" : "Domain Created",
@@ -301,7 +301,7 @@ export const AddPreviewDomain = ({
</form> </form>
<DialogFooter> <DialogFooter>
<Button isLoading={isLoading} form="hook-form" type="submit"> <Button isLoading={isPending} form="hook-form" type="submit">
{dictionary.submit} {dictionary.submit}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -43,7 +43,7 @@ interface Props {
export const ShowPreviewDeployments = ({ applicationId }: Props) => { export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { data } = api.application.one.useQuery({ applicationId }); const { data } = api.application.one.useQuery({ applicationId });
const { mutateAsync: deletePreviewDeployment, isLoading } = const { mutateAsync: deletePreviewDeployment, isPending } =
api.previewDeployment.delete.useMutation(); api.previewDeployment.delete.useMutation();
const { mutateAsync: redeployPreviewDeployment } = const { mutateAsync: redeployPreviewDeployment } =
@@ -57,8 +57,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
{ applicationId }, { applicationId },
{ {
enabled: !!applicationId, enabled: !!applicationId,
refetchInterval: (data) => refetchInterval: 2000,
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
}, },
); );
@@ -282,7 +281,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
isLoading={isLoading} isLoading={isPending}
className="text-red-600 hover:text-red-700 hover:bg-red-50" className="text-red-600 hover:text-red-700 hover:bg-red-50"
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, Plus, Settings2, X } from "lucide-react"; import { HelpCircle, Plus, Settings2, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -80,7 +80,7 @@ interface Props {
export const ShowPreviewSettings = ({ applicationId }: Props) => { export const ShowPreviewSettings = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isEnabled, setIsEnabled] = useState(false); const [isEnabled, setIsEnabled] = useState(false);
const { mutateAsync: updateApplication, isLoading } = const { mutateAsync: updateApplication, isPending } =
api.application.update.useMutation(); api.application.update.useMutation();
const { data, refetch } = api.application.one.useQuery({ applicationId }); const { data, refetch } = api.application.one.useQuery({ applicationId });
@@ -535,7 +535,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
Cancel Cancel
</Button> </Button>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-delete-application" form="hook-form-delete-application"
type="submit" type="submit"
> >

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -71,7 +71,7 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
}, },
); );
const { mutateAsync: updateApplication, isLoading } = const { mutateAsync: updateApplication, isPending } =
api.application.update.useMutation(); api.application.update.useMutation();
const { data: registries } = api.registry.all.useQuery(); const { data: registries } = api.registry.all.useQuery();
@@ -212,7 +212,7 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
/> />
)} )}
<Button type="submit" className="w-full" isLoading={isLoading}> <Button type="submit" className="w-full" isLoading={isPending}>
Save Settings Save Settings
</Button> </Button>
</form> </form>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { import {
CheckIcon, CheckIcon,
ChevronsUpDown, ChevronsUpDown,
@@ -220,8 +220,8 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache"); const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils(); const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm({
resolver: zodResolver(formSchema), resolver: standardSchemaResolver(formSchema),
defaultValues: { defaultValues: {
name: "", name: "",
cronExpression: "", cronExpression: "",
@@ -275,11 +275,11 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
} }
}, [form, schedule, scheduleId]); }, [form, schedule, scheduleId]);
const { mutateAsync, isLoading } = scheduleId const { mutateAsync, isPending } = scheduleId
? api.schedule.update.useMutation() ? api.schedule.update.useMutation()
: api.schedule.create.useMutation(); : api.schedule.create.useMutation();
const onSubmit = async (values: z.infer<typeof formSchema>) => { const onSubmit = async (values: z.output<typeof formSchema>) => {
if (!id && !scheduleId) return; if (!id && !scheduleId) return;
await mutateAsync({ await mutateAsync({
@@ -662,7 +662,7 @@ echo "Hello, world!"
)} )}
/> />
<Button type="submit" isLoading={isLoading} className="w-full"> <Button type="submit" isLoading={isPending} className="w-full">
{scheduleId ? "Update" : "Create"} Schedule {scheduleId ? "Update" : "Create"} Schedule
</Button> </Button>
</form> </form>

View File

@@ -51,7 +51,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
}, },
); );
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync: deleteSchedule, isLoading: isDeleting } = const { mutateAsync: deleteSchedule, isPending: isDeleting } =
api.schedule.delete.useMutation(); api.schedule.delete.useMutation();
const { mutateAsync: runManually } = api.schedule.runManually.useMutation(); const { mutateAsync: runManually } = api.schedule.runManually.useMutation();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react"; import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -43,7 +43,7 @@ interface Props {
export const UpdateApplication = ({ applicationId }: Props) => { export const UpdateApplication = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } = const { mutateAsync, error, isError, isPending } =
api.application.update.useMutation(); api.application.update.useMutation();
const { data } = api.application.one.useQuery( const { data } = api.application.one.useQuery(
{ {
@@ -148,7 +148,7 @@ export const UpdateApplication = ({ applicationId }: Props) => {
/> />
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-update-application" form="hook-form-update-application"
type="submit" type="submit"
> >

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react"; import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -116,7 +116,7 @@ export const HandleVolumeBackups = ({
const [keepLatestCountInput, setKeepLatestCountInput] = useState(""); const [keepLatestCountInput, setKeepLatestCountInput] = useState("");
const utils = api.useUtils(); const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: "", name: "",
@@ -195,7 +195,7 @@ export const HandleVolumeBackups = ({
} }
}, [form, volumeBackup, volumeBackupId]); }, [form, volumeBackup, volumeBackupId]);
const { mutateAsync, isLoading } = volumeBackupId const { mutateAsync, isPending } = volumeBackupId
? api.volumeBackups.update.useMutation() ? api.volumeBackups.update.useMutation()
: api.volumeBackups.create.useMutation(); : api.volumeBackups.create.useMutation();
@@ -207,7 +207,7 @@ export const HandleVolumeBackups = ({
await mutateAsync({ await mutateAsync({
...values, ...values,
keepLatestCount: preparedKeepLatestCount, keepLatestCount: preparedKeepLatestCount ?? undefined,
destinationId: values.destinationId, destinationId: values.destinationId,
volumeBackupId: volumeBackupId || "", volumeBackupId: volumeBackupId || "",
serviceType: volumeBackupType, serviceType: volumeBackupType,
@@ -630,7 +630,7 @@ export const HandleVolumeBackups = ({
)} )}
/> />
<Button type="submit" isLoading={isLoading} className="w-full"> <Button type="submit" isLoading={isPending} className="w-full">
{volumeBackupId ? "Update" : "Create"} Volume Backup {volumeBackupId ? "Update" : "Create"} Volume Backup
</Button> </Button>
</form> </form>

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { debounce } from "lodash"; import debounce from "lodash/debounce";
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react"; import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -53,27 +53,15 @@ interface Props {
} }
const RestoreBackupSchema = z.object({ const RestoreBackupSchema = z.object({
destinationId: z destinationId: z.string().min(1, {
.string({ message: "Destination is required",
required_error: "Please select a destination", }),
}) backupFile: z.string().min(1, {
.min(1, { message: "Backup file is required",
message: "Destination is required", }),
}), volumeName: z.string().min(1, {
backupFile: z message: "Volume name is required",
.string({ }),
required_error: "Please select a backup file",
})
.min(1, {
message: "Backup file is required",
}),
volumeName: z
.string({
required_error: "Please enter a volume name",
})
.min(1, {
message: "Volume name is required",
}),
}); });
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
@@ -83,7 +71,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
const { data: destinations = [] } = api.destination.all.useQuery(); const { data: destinations = [] } = api.destination.all.useQuery();
const form = useForm<z.infer<typeof RestoreBackupSchema>>({ const form = useForm({
defaultValues: { defaultValues: {
destinationId: "", destinationId: "",
backupFile: "", backupFile: "",
@@ -105,7 +93,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
debouncedSetSearch(value); debouncedSetSearch(value);
}; };
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{ {
destinationId: destinationId, destinationId: destinationId,
search: debouncedSearchTerm, search: debouncedSearchTerm,
@@ -294,7 +282,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
onValueChange={handleSearchChange} onValueChange={handleSearchChange}
className="h-9" className="h-9"
/> />
{isLoading ? ( {isPending ? (
<div className="py-6 text-center text-sm"> <div className="py-6 text-center text-sm">
Loading backup files... Loading backup files...
</div> </div>

View File

@@ -54,7 +54,7 @@ export const ShowVolumeBackups = ({
}, },
); );
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } = const { mutateAsync: deleteVolumeBackup, isPending: isDeleting } =
api.volumeBackups.delete.useMutation(); api.volumeBackups.delete.useMutation();
const { mutateAsync: runManually } = const { mutateAsync: runManually } =
api.volumeBackups.runManually.useMutation(); api.volumeBackups.runManually.useMutation();

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -52,7 +52,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, isLoading } = api.compose.update.useMutation(); const { mutateAsync, isPending } = api.compose.update.useMutation();
const form = useForm<AddCommand>({ const form = useForm<AddCommand>({
defaultValues: { defaultValues: {
@@ -128,7 +128,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
/> />
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit"> <Button isLoading={isPending} type="submit" className="w-fit">
Save Save
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { AlertTriangle, Loader2 } from "lucide-react"; import { AlertTriangle, Loader2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";

View File

@@ -1,5 +1,5 @@
import type { ServiceType } from "@dokploy/server/db/schema"; import type { ServiceType } from "@dokploy/server/db/schema";
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { Copy, Trash2 } from "lucide-react"; import { Copy, Trash2 } from "lucide-react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -74,7 +74,7 @@ export const DeleteService = ({ id, type }: Props) => {
mongo: () => api.mongo.remove.useMutation(), mongo: () => api.mongo.remove.useMutation(),
compose: () => api.compose.delete.useMutation(), compose: () => api.compose.delete.useMutation(),
}; };
const { mutateAsync, isLoading } = mutationMap[type] const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]() ? mutationMap[type]()
: api.mongo.remove.useMutation(); : api.mongo.remove.useMutation();
const { push } = useRouter(); const { push } = useRouter();
@@ -130,7 +130,7 @@ export const DeleteService = ({ id, type }: Props) => {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-red-500/10 " className="group hover:bg-red-500/10 "
isLoading={isLoading} isLoading={isPending}
> >
<Trash2 className="size-4 text-primary group-hover:text-red-500" /> <Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button> </Button>
@@ -228,7 +228,7 @@ export const DeleteService = ({ id, type }: Props) => {
</Button> </Button>
<Button <Button
isLoading={isLoading} isLoading={isPending}
disabled={isDisabled} disabled={isDisabled}
form="hook-form-delete-compose" form="hook-form-delete-compose"
type="submit" type="submit"

View File

@@ -28,9 +28,9 @@ export const ComposeActions = ({ composeId }: Props) => {
const { mutateAsync: update } = api.compose.update.useMutation(); const { mutateAsync: update } = api.compose.update.useMutation();
const { mutateAsync: deploy } = api.compose.deploy.useMutation(); const { mutateAsync: deploy } = api.compose.deploy.useMutation();
const { mutateAsync: redeploy } = api.compose.redeploy.useMutation(); const { mutateAsync: redeploy } = api.compose.redeploy.useMutation();
const { mutateAsync: start, isLoading: isStarting } = const { mutateAsync: start, isPending: isStarting } =
api.compose.start.useMutation(); api.compose.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } = const { mutateAsync: stop, isPending: isStopping } =
api.compose.stop.useMutation(); api.compose.stop.useMutation();
return ( return (
<div className="flex flex-row gap-4 w-full flex-wrap "> <div className="flex flex-row gap-4 w-full flex-wrap ">

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -34,7 +34,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
{ enabled: !!composeId }, { enabled: !!composeId },
); );
const { mutateAsync, isLoading } = api.compose.update.useMutation(); const { mutateAsync, isPending } = api.compose.update.useMutation();
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const form = useForm<AddComposeFile>({ const form = useForm<AddComposeFile>({
@@ -93,7 +93,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S // Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) { if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
e.preventDefault(); e.preventDefault();
form.handleSubmit(onSubmit)(); form.handleSubmit(onSubmit)();
} }
@@ -103,7 +103,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
return () => { return () => {
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
}, [form, onSubmit, isLoading]); }, [form, onSubmit, isPending]);
return ( return (
<> <>
@@ -167,7 +167,7 @@ services:
<Button <Button
type="submit" type="submit"
form="hook-form-save-compose-file" form="hook-form-save-compose-file"
isLoading={isLoading} isLoading={isPending}
className="lg:w-fit w-full" className="lg:w-fit w-full"
> >
Save Save

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -74,10 +74,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
api.bitbucket.bitbucketProviders.useQuery(); api.bitbucket.bitbucketProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId }); const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingBitbucketProvider } = const { mutateAsync, isPending: isSavingBitbucketProvider } =
api.compose.update.useMutation(); api.compose.update.useMutation();
const form = useForm<BitbucketProvider>({ const form = useForm({
defaultValues: { defaultValues: {
composePath: "./docker-compose.yml", composePath: "./docker-compose.yml",
repository: { repository: {
@@ -335,7 +335,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -352,7 +352,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import { KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@@ -58,9 +58,9 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data: sshKeys } = api.sshKey.all.useQuery(); const { data: sshKeys } = api.sshKey.all.useQuery();
const router = useRouter(); const router = useRouter();
const { mutateAsync, isLoading } = api.compose.update.useMutation(); const { mutateAsync, isPending } = api.compose.update.useMutation();
const form = useForm<GitProvider>({ const form = useForm({
defaultValues: { defaultValues: {
branch: "", branch: "",
repositoryURL: "", repositoryURL: "",
@@ -318,7 +318,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
</div> </div>
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">
<Button type="submit" className="w-fit" isLoading={isLoading}> <Button type="submit" className="w-fit" isLoading={isPending}>
Save{" "} Save{" "}
</Button> </Button>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -72,10 +72,10 @@ interface Props {
export const SaveGiteaProviderCompose = ({ composeId }: Props) => { export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery(); const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId }); const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGiteaProvider } = const { mutateAsync, isPending: isSavingGiteaProvider } =
api.compose.update.useMutation(); api.compose.update.useMutation();
const form = useForm<GiteaProvider>({ const form = useForm({
defaultValues: { defaultValues: {
composePath: "./docker-compose.yml", composePath: "./docker-compose.yml",
repository: { repository: {
@@ -331,7 +331,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -72,10 +72,10 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const { data: githubProviders } = api.github.githubProviders.useQuery(); const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId }); const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGithubProvider } = const { mutateAsync, isPending: isSavingGithubProvider } =
api.compose.update.useMutation(); api.compose.update.useMutation();
const form = useForm<GithubProvider>({ const form = useForm({
defaultValues: { defaultValues: {
composePath: "./docker-compose.yml", composePath: "./docker-compose.yml",
repository: { repository: {
@@ -94,7 +94,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository"); const repository = form.watch("repository");
const githubId = form.watch("githubId"); const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType"); const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } = const { data: repositories, isPending: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery( api.github.getGithubRepositories.useQuery(
{ {
githubId, githubId,
@@ -321,7 +321,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -338,7 +338,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
@@ -74,10 +74,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery(); const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data, refetch } = api.compose.one.useQuery({ composeId }); const { data, refetch } = api.compose.one.useQuery({ composeId });
const { mutateAsync, isLoading: isSavingGitlabProvider } = const { mutateAsync, isPending: isSavingGitlabProvider } =
api.compose.update.useMutation(); api.compose.update.useMutation();
const form = useForm<GitlabProvider>({ const form = useForm({
defaultValues: { defaultValues: {
composePath: "./docker-compose.yml", composePath: "./docker-compose.yml",
repository: { repository: {
@@ -353,7 +353,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{status === "loading" && fetchStatus === "fetching" {status === "pending" && fetchStatus === "fetching"
? "Loading...." ? "Loading...."
: field.value : field.value
? branches?.find( ? branches?.find(
@@ -370,7 +370,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
placeholder="Search branch..." placeholder="Search branch..."
className="h-9" className="h-9"
/> />
{status === "loading" && fetchStatus === "fetching" && ( {status === "pending" && fetchStatus === "fetching" && (
<span className="py-6 text-center text-sm text-muted-foreground"> <span className="py-6 text-center text-sm text-muted-foreground">
Loading Branches.... Loading Branches....
</span> </span>

View File

@@ -27,13 +27,13 @@ interface Props {
} }
export const ShowProviderFormCompose = ({ composeId }: Props) => { export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: githubProviders, isLoading: isLoadingGithub } = const { data: githubProviders, isPending: isLoadingGithub } =
api.github.githubProviders.useQuery(); api.github.githubProviders.useQuery();
const { data: gitlabProviders, isLoading: isLoadingGitlab } = const { data: gitlabProviders, isPending: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery(); api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } = const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
api.bitbucket.bitbucketProviders.useQuery(); api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders, isLoading: isLoadingGitea } = const { data: giteaProviders, isPending: isLoadingGitea } =
api.gitea.giteaProviders.useQuery(); api.gitea.giteaProviders.useQuery();
const { mutateAsync: disconnectGitProvider } = const { mutateAsync: disconnectGitProvider } =

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";

View File

@@ -32,7 +32,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
}, },
); );
const { mutateAsync, isLoading } = api.compose.fetchSourceType.useMutation(); const { mutateAsync, isPending } = api.compose.fetchSourceType.useMutation();
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -66,7 +66,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview your docker-compose file with added domains. Note: At least Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect. one domain must be specified for this conversion to take effect.
</AlertBlock> </AlertBlock>
{isLoading ? ( {isPending ? (
<div className="flex flex-row items-center justify-center min-h-[25rem] border p-4 rounded-md"> <div className="flex flex-row items-center justify-center min-h-[25rem] border p-4 rounded-md">
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" /> <Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
</div> </div>
@@ -82,7 +82,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
<div className="flex flex-row gap-2 justify-end my-4"> <div className="flex flex-row gap-2 justify-end my-4">
<Button <Button
variant="secondary" variant="secondary"
isLoading={isLoading} isLoading={isPending}
onClick={() => { onClick={() => {
mutateAsync({ composeId }) mutateAsync({ composeId })
.then(() => { .then(() => {

View File

@@ -41,7 +41,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native"); const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>(); const [containerId, setContainerId] = useState<string | undefined>();
const { data: services, isLoading: servicesLoading } = const { data: services, isPending: servicesLoading } =
api.docker.getStackContainersByAppName.useQuery( api.docker.getStackContainersByAppName.useQuery(
{ {
appName, appName,
@@ -52,7 +52,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
}, },
); );
const { data: containers, isLoading: containersLoading } = const { data: containers, isPending: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery( api.docker.getContainersByAppNameMatch.useQuery(
{ {
appName, appName,

View File

@@ -42,7 +42,7 @@ export const ShowDockerLogsCompose = ({
appType, appType,
serverId, serverId,
}: Props) => { }: Props) => {
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery( const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{ {
appName, appName,
appType, appType,
@@ -73,7 +73,7 @@ export const ShowDockerLogsCompose = ({
<Label>Select a container to view logs</Label> <Label>Select a container to view logs</Label>
<Select onValueChange={setContainerId} value={containerId}> <Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger> <SelectTrigger>
{isLoading ? ( {isPending ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground"> <div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground">
<span>Loading...</span> <span>Loading...</span>
<Loader2 className="animate-spin size-4" /> <Loader2 className="animate-spin size-4" />

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react"; import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -43,7 +43,7 @@ interface Props {
export const UpdateCompose = ({ composeId }: Props) => { export const UpdateCompose = ({ composeId }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } = const { mutateAsync, error, isError, isPending } =
api.compose.update.useMutation(); api.compose.update.useMutation();
const { data } = api.compose.one.useQuery( const { data } = api.compose.one.useQuery(
{ {
@@ -148,7 +148,7 @@ export const UpdateCompose = ({ composeId }: Props) => {
/> />
<DialogFooter> <DialogFooter>
<Button <Button
isLoading={isLoading} isLoading={isPending}
form="hook-form-update-compose" form="hook-form-update-compose"
type="submit" type="submit"
> >

View File

@@ -1,4 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { import {
CheckIcon, CheckIcon,
ChevronsUpDown, ChevronsUpDown,
@@ -192,7 +192,7 @@ export const HandleBackup = ({
}: Props) => { }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { data, isLoading } = api.destination.all.useQuery(); const { data, isPending } = api.destination.all.useQuery();
const { data: backup } = api.backup.one.useQuery( const { data: backup } = api.backup.one.useQuery(
{ {
backupId: backupId ?? "", backupId: backupId ?? "",
@@ -202,12 +202,12 @@ export const HandleBackup = ({
}, },
); );
const [cacheType, setCacheType] = useState<CacheType>("cache"); const [cacheType, setCacheType] = useState<CacheType>("cache");
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } = const { mutateAsync: createBackup, isPending: isCreatingPostgresBackup } =
backupId backupId
? api.backup.update.useMutation() ? api.backup.update.useMutation()
: api.backup.create.useMutation(); : api.backup.create.useMutation();
const form = useForm<z.infer<typeof Schema>>({ const form = useForm({
defaultValues: { defaultValues: {
database: databaseType === "web-server" ? "dokploy" : "", database: databaseType === "web-server" ? "dokploy" : "",
destinationId: "", destinationId: "",
@@ -396,7 +396,7 @@ export const HandleBackup = ({
!field.value && "text-muted-foreground", !field.value && "text-muted-foreground",
)} )}
> >
{isLoading {isPending
? "Loading...." ? "Loading...."
: field.value : field.value
? data?.find( ? data?.find(
@@ -415,7 +415,7 @@ export const HandleBackup = ({
placeholder="Search Destination..." placeholder="Search Destination..."
className="h-9" className="h-9"
/> />
{isLoading && ( {isPending && (
<span className="py-6 text-center text-sm"> <span className="py-6 text-center text-sm">
Loading Destinations.... Loading Destinations....
</span> </span>
@@ -613,6 +613,7 @@ export const HandleBackup = ({
type="number" type="number"
placeholder={"keeps all the backups if left empty"} placeholder={"keeps all the backups if left empty"}
{...field} {...field}
value={field.value as string}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>

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