mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-17 21:25:23 +02:00
Compare commits
174 Commits
feat/add-w
...
patches-im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4eb0bfea1 | ||
|
|
ce9ba60902 | ||
|
|
744ebab15a | ||
|
|
17da1d5b3c | ||
|
|
f7613d9375 | ||
|
|
a43ad106f2 | ||
|
|
0e26c5023b | ||
|
|
f4a4530481 | ||
|
|
00dc3fae11 | ||
|
|
1da23f8888 | ||
|
|
bee4e4639c | ||
|
|
bd5b27ad51 | ||
|
|
b391abfd5c | ||
|
|
21a6657e00 | ||
|
|
d348ad5556 | ||
|
|
5d8b7b9b99 | ||
|
|
f5fa39b97e | ||
|
|
0a3a90c4e9 | ||
|
|
f440df343a | ||
|
|
4ec282b2f3 | ||
|
|
c039e638a6 | ||
|
|
65ffc63da4 | ||
|
|
5ba120567f | ||
|
|
8a335789b3 | ||
|
|
d420311507 | ||
|
|
a01ace12e8 | ||
|
|
24c022f837 | ||
|
|
ecd81eb7fa | ||
|
|
f2e4a96154 | ||
|
|
08ba24c252 | ||
|
|
ff55270b52 | ||
|
|
f78819d81a | ||
|
|
79e02483ad | ||
|
|
f25ed46dbc | ||
|
|
7ad09c0d0d | ||
|
|
a212d42495 | ||
|
|
51095e3ac5 | ||
|
|
a897fe6115 | ||
|
|
a0d9f06a35 | ||
|
|
f1d0fb95f4 | ||
|
|
4bc494e009 | ||
|
|
110bdce38c | ||
|
|
b9e700243e | ||
|
|
bc39addfa8 | ||
|
|
2532934cdf | ||
|
|
f4b5a589b6 | ||
|
|
105562bdcb | ||
|
|
16359e21a2 | ||
|
|
9451958193 | ||
|
|
1a810790cd | ||
|
|
e426c89cb2 | ||
|
|
325a0aeedf | ||
|
|
a8293b7b5c | ||
|
|
54bd25da39 | ||
|
|
e4c440b265 | ||
|
|
e39f0fee77 | ||
|
|
5b48e45536 | ||
|
|
3a7f76e33e | ||
|
|
a54c84a138 | ||
|
|
8ba26f01e3 | ||
|
|
9bc88eba72 | ||
|
|
b741618251 | ||
|
|
c0328ab63f | ||
|
|
425bcf8958 | ||
|
|
26d4058457 | ||
|
|
ccaac28f08 | ||
|
|
a1a348e22d | ||
|
|
ad29bb6ec2 | ||
|
|
aa2e0e81c6 | ||
|
|
3750cdab44 | ||
|
|
6cf448ba80 | ||
|
|
3e64647d0d | ||
|
|
dde00fc380 | ||
|
|
f4ad3dae35 | ||
|
|
85a8ec8ba9 | ||
|
|
c68525aa59 | ||
|
|
91d6365275 | ||
|
|
35a7445a09 | ||
|
|
4607b15a85 | ||
|
|
4eae1a5c14 | ||
|
|
5381b13813 | ||
|
|
66ae8e1fff | ||
|
|
1aa05eaa8d | ||
|
|
4f13c25ca2 | ||
|
|
83599cee37 | ||
|
|
1c4e95d8e3 | ||
|
|
97f1105cf4 | ||
|
|
c65026353a | ||
|
|
8872dc178c | ||
|
|
fa0c2ec5e3 | ||
|
|
f9eda8e95d | ||
|
|
5b2b0db686 | ||
|
|
6576731842 | ||
|
|
ec7bf9fd2f | ||
|
|
bc053744fc | ||
|
|
af87614cb0 | ||
|
|
b2484da2af | ||
|
|
5d3c05d291 | ||
|
|
40accfbf60 | ||
|
|
3f0558d077 | ||
|
|
7ae3d7d906 | ||
|
|
6877ebe027 | ||
|
|
51e881d831 | ||
|
|
80bbb752b6 | ||
|
|
3c9945ec35 | ||
|
|
4d8c358b33 | ||
|
|
4b82659a48 | ||
|
|
d77c562c84 | ||
|
|
37ea75be3e | ||
|
|
82158ed34d | ||
|
|
9ab98c9a63 | ||
|
|
ca7d3f8cb3 | ||
|
|
66448ff6c2 | ||
|
|
31d47efb1e | ||
|
|
33802f554a | ||
|
|
38265fd921 | ||
|
|
ca2efc5c68 | ||
|
|
dfcb422294 | ||
|
|
47470e2343 | ||
|
|
bac9dd5c31 | ||
|
|
65dab84e7f | ||
|
|
3a0da19ea8 | ||
|
|
5e460e6b4f | ||
|
|
9299f04f74 | ||
|
|
2746133252 | ||
|
|
bde192c1e7 | ||
|
|
99646f887b | ||
|
|
542ccc4479 | ||
|
|
9910c0e602 | ||
|
|
4f0d707905 | ||
|
|
a86fe46b7b | ||
|
|
139c06b63d | ||
|
|
999dc7d360 | ||
|
|
dc74d3057a | ||
|
|
ac833ef265 | ||
|
|
00e31f399e | ||
|
|
8001e5d24a | ||
|
|
cfb9534e06 | ||
|
|
c2894260dc | ||
|
|
582f493f3f | ||
|
|
8335f40238 | ||
|
|
f6f0921560 | ||
|
|
c739c67616 | ||
|
|
dc8148ae51 | ||
|
|
3307f62183 | ||
|
|
2b36381f8d | ||
|
|
de2579401c | ||
|
|
945406adc5 | ||
|
|
1e7522d173 | ||
|
|
2b72b4888c | ||
|
|
8b1cc949c0 | ||
|
|
a70018f70a | ||
|
|
71b87895eb | ||
|
|
354407cd12 | ||
|
|
766fd00be5 | ||
|
|
c31e970172 | ||
|
|
c56def9c97 | ||
|
|
aa558b3a8c | ||
|
|
11082f25d7 | ||
|
|
00ce8cad1b | ||
|
|
dc756e2bbb | ||
|
|
fb06cf8e55 | ||
|
|
69ba901535 | ||
|
|
4667cb525f | ||
|
|
21b1652259 | ||
|
|
25d37b76a1 | ||
|
|
ad382f1fe5 | ||
|
|
d5b0e3193a | ||
|
|
43228fc51b | ||
|
|
ba8a334fbe | ||
|
|
6c90075a64 | ||
|
|
6271f3bb1a | ||
|
|
57eee45dbb | ||
|
|
0a401843f8 |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -8,7 +8,7 @@ Before submitting this PR, please make sure that:
|
||||
|
||||
- [ ] You created a dedicated branch based on the `canary` branch.
|
||||
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||
- [ ] You have tested this PR in your local instance.
|
||||
- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.
|
||||
|
||||
## Issues related (if applicable)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
|
||||
|
||||
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
|
||||
Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues.
|
||||
|
||||
We have a few guidelines to follow when contributing to this project:
|
||||
|
||||
@@ -11,6 +11,7 @@ We have a few guidelines to follow when contributing to this project:
|
||||
- [Development](#development)
|
||||
- [Build](#build)
|
||||
- [Pull Request](#pull-request)
|
||||
- [Important Considerations](#important-considerations-for-pull-requests)
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -162,8 +163,9 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
|
||||
- If your pull request fixes an open issue, please reference the issue in the pull request description.
|
||||
- Once your pull request is merged, you will be automatically added as a contributor to the project.
|
||||
|
||||
**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.
|
||||
- **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.
|
||||
- **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`).
|
||||
|
||||
@@ -65,4 +65,8 @@ RUN curl -sSL https://railpack.com/install.sh | bash
|
||||
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "start" ]
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||
|
||||
47
README.md
47
README.md
@@ -12,24 +12,8 @@
|
||||
<br />
|
||||
|
||||
|
||||
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://tuple.app/dokploy">
|
||||
<img src=".github/sponsors/tuple.png" alt="Tuple's sponsorship image" width="400"/>
|
||||
</a>
|
||||
|
||||
### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy)
|
||||
[Available for MacOS & Windows](https://tuple.app/dokploy)<br>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
||||
|
||||
|
||||
## ✨ Features
|
||||
|
||||
Dokploy includes multiple features to make your life easier.
|
||||
@@ -60,40 +44,9 @@ curl -sSL https://dokploy.com/install.sh | sh
|
||||
|
||||
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
|
||||
## ♥️ Sponsors
|
||||
|
||||
🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features.
|
||||
|
||||
[Dokploy Open Collective](https://opencollective.com/dokploy)
|
||||
|
||||
[Github Sponsors](https://github.com/sponsors/Siumauricio)
|
||||
|
||||
## Sponsors
|
||||
|
||||
| Sponsor | Logo | Supporter Level |
|
||||
|---------|:----:|----------------|
|
||||
| [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) | <img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="200"/> | 🎖 Hero Sponsor |
|
||||
| [LX Aer](https://www.lxaer.com/?ref=dokploy) | <img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/> | 🎖 Hero Sponsor |
|
||||
| [LinkDR](https://linkdr.com/?ref=dokploy) | <img src="https://dokploy.com/linkdr-logo.svg" alt="LinkDR" width="100"/> | 🎖 Hero Sponsor |
|
||||
| [LambdaTest](https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor) | <img src="https://www.lambdatest.com/blue-logo.png" alt="LambdaTest" width="200"/> | 🎖 Hero Sponsor |
|
||||
| [Awesome Tools](https://awesome.tools/) | <img src=".github/sponsors/awesome.png" alt="Awesome Tools" width="100"/> | 🎖 Hero Sponsor |
|
||||
| [Supafort](https://supafort.com/?ref=dokploy) | <img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="200"/> | 🥇 Premium Supporter |
|
||||
| [Agentdock](https://agentdock.ai/?ref=dokploy) | <img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/> | 🥇 Premium Supporter |
|
||||
| [AmericanCloud](https://americancloud.com/?ref=dokploy) | <img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="200"/> | 🥈 Elite Contributor |
|
||||
| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | <img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/> | 🥈 Elite Contributor |
|
||||
| [Cloudblast](https://cloudblast.io/?ref=dokploy) | <img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" alt="Cloudblast.io" width="150"/> | 🥉 Supporting Member |
|
||||
| [Synexa](https://synexa.ai/?ref=dokploy) | <img src=".github/sponsors/synexa.png" alt="Synexa" width="100"/> | 🥉 Supporting Member |
|
||||
|
||||
### Community Backers 🤝
|
||||
|
||||
#### Organizations:
|
||||
|
||||
[Sponsors on Open Collective](https://opencollective.com/dokploy)
|
||||
|
||||
#### Individuals:
|
||||
|
||||
[](https://opencollective.com/dokploy)
|
||||
|
||||
### Contributors 🤝
|
||||
|
||||
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/zod-validator": "0.3.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.7.10",
|
||||
"hono": "^4.11.7",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"react": "18.2.0",
|
||||
@@ -23,7 +23,7 @@
|
||||
"zod": "^3.25.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.51",
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"tsx": "^4.16.2",
|
||||
|
||||
@@ -4,21 +4,30 @@ import { describe, expect, it } from "vitest";
|
||||
describe("addDokployNetworkToService", () => {
|
||||
it("should add network to an empty array", () => {
|
||||
const result = addDokployNetworkToService([]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should not add duplicate network to an array", () => {
|
||||
const result = addDokployNetworkToService(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should add network to an existing array with other networks", () => {
|
||||
const result = addDokployNetworkToService(["other-network"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should add network to an object if networks is an object", () => {
|
||||
const result = addDokployNetworkToService({ "other-network": {} });
|
||||
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
|
||||
expect(result).toEqual({
|
||||
"other-network": {},
|
||||
"dokploy-network": {},
|
||||
default: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not duplicate default network when already present", () => {
|
||||
const result = addDokployNetworkToService(["default", "dokploy-network"]);
|
||||
expect(result).toEqual(["default", "dokploy-network"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ApplicationNested } from "@dokploy/server";
|
||||
@@ -8,6 +9,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
|
||||
|
||||
// Mock constants to avoid load error
|
||||
vi.mock("@dokploy/server/constants", () => ({
|
||||
paths: () => ({
|
||||
LOGS_PATH: "/tmp/dokploy-test-real/logs",
|
||||
APPLICATIONS_PATH: "/tmp/dokploy-test-real/applications",
|
||||
PATCH_REPOS_PATH: "/tmp/dokploy-test-real/patch-repos",
|
||||
}),
|
||||
IS_CLOUD: false,
|
||||
docker: {},
|
||||
}));
|
||||
|
||||
// Mock ONLY database and notifications
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const createChainableMock = (): any => {
|
||||
@@ -67,6 +79,16 @@ vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||
createRollback: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/patch", async (importOriginal) => {
|
||||
const actual = await importOriginal<
|
||||
typeof import("@dokploy/server/services/patch")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
findPatchesByApplicationId: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
// NOT mocked (executed for real):
|
||||
// - execAsync
|
||||
// - cloneGitRepository
|
||||
@@ -78,6 +100,11 @@ import * as adminService from "@dokploy/server/services/admin";
|
||||
import * as applicationService from "@dokploy/server/services/application";
|
||||
import { deployApplication } from "@dokploy/server/services/application";
|
||||
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||
import * as patchService from "@dokploy/server/services/patch";
|
||||
import { generatePatch } from "@dokploy/server/services/patch";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const createMockApplication = (
|
||||
overrides: Partial<ApplicationNested> = {},
|
||||
@@ -474,6 +501,105 @@ describe(
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
it(
|
||||
"should REALLY apply patches from database during deployment",
|
||||
async () => {
|
||||
// 1. Setup local temporary git repo
|
||||
const tempRepo = await mkdtemp(join(tmpdir(), "real-patch-repo-"));
|
||||
// Helper for local git commands
|
||||
const execLocal = async (cmd: string) => execAsync(cmd, { cwd: tempRepo });
|
||||
|
||||
await execLocal("git init");
|
||||
await execLocal("git config user.email 'test@dokploy.com'");
|
||||
await execLocal("git config user.name 'Dokploy Test'");
|
||||
|
||||
// Create a simple Dockerfile and server script
|
||||
// We use a simple python server to verify output
|
||||
await writeFile(join(tempRepo, "app.py"), "print('Original App')\n");
|
||||
await writeFile(
|
||||
join(tempRepo, "Dockerfile"),
|
||||
"FROM python:3.9-slim\nCOPY app.py .\nCMD [\"python\", \"app.py\"]\n",
|
||||
);
|
||||
|
||||
await execLocal("git add .");
|
||||
await execLocal("git commit -m 'Initial commit'");
|
||||
// Ensure master/main branch exists (git init might create master or main depending on config)
|
||||
// We force create a branch named 'main' to be consistent
|
||||
await execLocal("git checkout -b main || git checkout main");
|
||||
|
||||
// 2. Mock Application to use this local repo
|
||||
const patchAppName = `real-patch-app-${Date.now()}`;
|
||||
const patchApp = createMockApplication({
|
||||
appName: patchAppName,
|
||||
buildType: "dockerfile",
|
||||
customGitUrl: `file://${tempRepo}`,
|
||||
customGitBranch: "main",
|
||||
dockerfile: "Dockerfile",
|
||||
});
|
||||
currentAppName = patchAppName;
|
||||
allTestAppNames.push(patchAppName);
|
||||
|
||||
// Setup standard mocks
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
patchApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
patchApp as any,
|
||||
);
|
||||
|
||||
// 3. Generate a patch
|
||||
// We modify the file, generate patch, and then reset.
|
||||
const newContent = "print('Patched App')\n";
|
||||
const patchContent = await generatePatch({
|
||||
codePath: tempRepo,
|
||||
filePath: "app.py",
|
||||
newContent,
|
||||
serverId: null,
|
||||
});
|
||||
|
||||
// 4. Mock patch service to return this patch
|
||||
vi.mocked(patchService.findPatchesByApplicationId).mockResolvedValue([
|
||||
{
|
||||
patchId: "test-patch-1",
|
||||
applicationId: "test-app-id",
|
||||
composeId: null,
|
||||
filePath: "app.py",
|
||||
content: patchContent,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
} as any,
|
||||
]);
|
||||
|
||||
console.log(`\n🚀 Testing deployment with patch: ${currentAppName}`);
|
||||
|
||||
// 5. Deploy
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Patch Test",
|
||||
descriptionLog: "Testing patch application",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// 6. Verify Log contains "Applying patch"
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
// The implementation logs "Applying patch: ..."
|
||||
expect(logContent).toContain("Applying patch");
|
||||
expect(logContent).toContain("app.py");
|
||||
console.log("✅ Verified patch execution logs");
|
||||
|
||||
// 7. Verify the deployed image contains the patched code
|
||||
// We run the image and check output
|
||||
const { stdout: runOutput } = await execAsync(
|
||||
`docker run --rm ${patchAppName}`,
|
||||
);
|
||||
expect(runOutput.trim()).toBe("Patched App");
|
||||
console.log("✅ Verified patched output:", runOutput.trim());
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
@@ -147,6 +147,7 @@ const baseApp: ApplicationNested = {
|
||||
dockerContextPath: null,
|
||||
rollbackActive: false,
|
||||
stopGracePeriodSwarm: null,
|
||||
ulimitsSwarm: null,
|
||||
};
|
||||
|
||||
describe("unzipDrop using real zip files", () => {
|
||||
|
||||
106
apps/dokploy/__test__/patches/patch.integration.test.ts
Normal file
106
apps/dokploy/__test__/patches/patch.integration.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
import { generatePatch } from "@dokploy/server/services/patch";
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execAsyncLocal = promisify(exec);
|
||||
|
||||
describe("Patch System Integration", () => {
|
||||
let tempDir: string;
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should generate a patch that can be successfully applied via git", async () => {
|
||||
// Setup repo
|
||||
tempDir = await mkdtemp(join(tmpdir(), "dokploy-patch-test-"));
|
||||
const fileName = "test.txt";
|
||||
const filePath = join(tempDir, fileName);
|
||||
|
||||
await execAsyncLocal("git init", { cwd: tempDir });
|
||||
await execAsyncLocal("git config user.email 'test@test.com'", { cwd: tempDir });
|
||||
await execAsyncLocal("git config user.name 'Test'", { cwd: tempDir });
|
||||
|
||||
// Original content
|
||||
await writeFile(filePath, "line1\nline2\n");
|
||||
await execAsyncLocal(`git add ${fileName}`, { cwd: tempDir });
|
||||
await execAsyncLocal("git commit -m 'init'", { cwd: tempDir });
|
||||
|
||||
// Generate patch (modify content)
|
||||
const newContent = "line1\nline2\nline3\n";
|
||||
const patchContent = await generatePatch({
|
||||
codePath: tempDir,
|
||||
filePath: fileName,
|
||||
newContent,
|
||||
serverId: null,
|
||||
});
|
||||
|
||||
// Verify patch format
|
||||
expect(patchContent.endsWith("\n")).toBe(true);
|
||||
|
||||
// Reset file (generatePatch does reset, but ensure it)
|
||||
await execAsyncLocal("git checkout .", { cwd: tempDir });
|
||||
const savedContent = await readFile(filePath, "utf-8");
|
||||
expect(savedContent).toBe("line1\nline2\n");
|
||||
|
||||
// Apply patch verification
|
||||
// We simulate what Deployment Service does: write patch to file and run git apply
|
||||
const patchFile = join(tempDir, "changes.patch");
|
||||
await writeFile(patchFile, patchContent);
|
||||
|
||||
try {
|
||||
await execAsyncLocal(`git apply --whitespace=fix ${patchFile}`, { cwd: tempDir });
|
||||
} catch (e: any) {
|
||||
console.error("Git apply failed:", e.message);
|
||||
console.log("Patch content:", JSON.stringify(patchContent));
|
||||
throw e;
|
||||
}
|
||||
|
||||
const appliedContent = await readFile(filePath, "utf-8");
|
||||
expect(appliedContent).toBe(newContent);
|
||||
});
|
||||
|
||||
it("should handle files created without trailing newline", async () => {
|
||||
// Setup repo
|
||||
tempDir = await mkdtemp(join(tmpdir(), "dokploy-patch-test-noline-"));
|
||||
const fileName = "noline.txt";
|
||||
const filePath = join(tempDir, fileName);
|
||||
|
||||
await execAsyncLocal("git init", { cwd: tempDir });
|
||||
await execAsyncLocal("git config user.email 'test@test.com'", { cwd: tempDir });
|
||||
await execAsyncLocal("git config user.name 'Test'", { cwd: tempDir });
|
||||
|
||||
// Original content WITHOUT newline
|
||||
await writeFile(filePath, "line1");
|
||||
await execAsyncLocal(`git add ${fileName}`, { cwd: tempDir });
|
||||
await execAsyncLocal("git commit -m 'init'", { cwd: tempDir });
|
||||
|
||||
// Generate patch
|
||||
const newContent = "line1\nline2";
|
||||
const patchContent = await generatePatch({
|
||||
codePath: tempDir,
|
||||
filePath: fileName,
|
||||
newContent,
|
||||
serverId: null,
|
||||
});
|
||||
|
||||
// Verify patch format
|
||||
expect(patchContent.endsWith("\n")).toBe(true);
|
||||
|
||||
// Apply patch
|
||||
const patchFile = join(tempDir, "changes.patch");
|
||||
await writeFile(patchFile, patchContent);
|
||||
|
||||
await execAsyncLocal(`git apply --whitespace=fix ${patchFile}`, { cwd: tempDir });
|
||||
|
||||
const appliedContent = await readFile(filePath, "utf-8");
|
||||
expect(appliedContent).toBe(newContent);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ type MockCreateServiceOptions = {
|
||||
TaskTemplate?: {
|
||||
ContainerSpec?: {
|
||||
StopGracePeriod?: number;
|
||||
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
@@ -13,11 +14,11 @@ type MockCreateServiceOptions = {
|
||||
|
||||
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
|
||||
vi.hoisted(() => {
|
||||
const inspect = vi.fn<[], Promise<never>>();
|
||||
const inspect = vi.fn<() => Promise<never>>();
|
||||
const getService = vi.fn(() => ({ inspect }));
|
||||
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
|
||||
async () => undefined,
|
||||
);
|
||||
const createService = vi.fn<
|
||||
(opts: MockCreateServiceOptions) => Promise<void>
|
||||
>(async () => undefined);
|
||||
const getRemoteDocker = vi.fn(async () => ({
|
||||
getService,
|
||||
createService,
|
||||
@@ -57,6 +58,7 @@ const createApplication = (
|
||||
},
|
||||
replicas: 1,
|
||||
stopGracePeriodSwarm: 0n,
|
||||
ulimitsSwarm: null,
|
||||
serverId: "server-id",
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
@@ -80,7 +82,9 @@ describe("mechanizeDockerContainer", () => {
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
@@ -97,7 +101,9 @@ describe("mechanizeDockerContainer", () => {
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
@@ -106,4 +112,50 @@ describe("mechanizeDockerContainer", () => {
|
||||
"StopGracePeriod",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => {
|
||||
const ulimits = [
|
||||
{ Name: "nofile", Soft: 10000, Hard: 20000 },
|
||||
{ Name: "nproc", Soft: 4096, Hard: 8192 },
|
||||
];
|
||||
const application = createApplication({ ulimitsSwarm: ulimits });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits);
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is null", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: null });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is an empty array", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: [] });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
});
|
||||
|
||||
40
apps/dokploy/__test__/setup.ts
Normal file
40
apps/dokploy/__test__/setup.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Mock the DB module so tests that import from @dokploy/server (barrel)
|
||||
* never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs).
|
||||
* Without this, loading the server barrel pulls in lib/auth and db, which
|
||||
* connect to localhost:5432 and cause ECONNREFUSED.
|
||||
*/
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const chain = () => chain;
|
||||
chain.set = () => chain;
|
||||
chain.where = () => chain;
|
||||
chain.values = () => chain;
|
||||
chain.returning = () => Promise.resolve([{}]);
|
||||
chain.then = undefined;
|
||||
|
||||
const tableMock = {
|
||||
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
insert: vi.fn(() => Promise.resolve([{}])),
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
};
|
||||
const createQueryMock = () => tableMock;
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(() => chain),
|
||||
insert: vi.fn(() => ({
|
||||
values: () => ({ returning: () => Promise.resolve([{}]) }),
|
||||
})),
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
query: new Proxy({} as Record<string, typeof tableMock>, {
|
||||
get: () => tableMock,
|
||||
}),
|
||||
},
|
||||
dbUrl: "postgres://mock:mock@localhost:5432/mock",
|
||||
};
|
||||
});
|
||||
@@ -125,6 +125,7 @@ const baseApp: ApplicationNested = {
|
||||
username: null,
|
||||
dockerContextPath: null,
|
||||
stopGracePeriodSwarm: null,
|
||||
ulimitsSwarm: null,
|
||||
};
|
||||
|
||||
const baseDomain: Domain = {
|
||||
|
||||
@@ -7,10 +7,15 @@ export default defineConfig({
|
||||
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
|
||||
pool: "forks",
|
||||
setupFiles: [path.resolve(__dirname, "setup.ts")],
|
||||
},
|
||||
define: {
|
||||
"process.env": {
|
||||
NODE: "test",
|
||||
GITHUB_CLIENT_ID: "test",
|
||||
GITHUB_CLIENT_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
HealthCheckForm,
|
||||
LabelsForm,
|
||||
ModeForm,
|
||||
NetworkForm,
|
||||
PlacementForm,
|
||||
RestartPolicyForm,
|
||||
RollbackConfigForm,
|
||||
@@ -79,6 +80,13 @@ const menuItems: MenuItem[] = [
|
||||
docDescription:
|
||||
"Set service mode to either 'Replicated' with a specified number of tasks (Replicas), or 'Global' (one task per node).",
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
label: "Network",
|
||||
description: "Configure network attachments",
|
||||
docDescription:
|
||||
"Attach the service to one or more networks. Specify the network name (Target) and optional network aliases for service discovery.",
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
label: "Labels",
|
||||
@@ -190,6 +198,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
<RollbackConfigForm id={id} type={type} />
|
||||
)}
|
||||
{activeMenu === "mode" && <ModeForm id={id} type={type} />}
|
||||
{activeMenu === "network" && <NetworkForm id={id} type={type} />}
|
||||
{activeMenu === "labels" && <LabelsForm id={id} type={type} />}
|
||||
{activeMenu === "stop-grace-period" && (
|
||||
<StopGracePeriodForm id={id} type={type} />
|
||||
|
||||
@@ -2,6 +2,7 @@ export { EndpointSpecForm } from "./endpoint-spec-form";
|
||||
export { HealthCheckForm } from "./health-check-form";
|
||||
export { LabelsForm } from "./labels-form";
|
||||
export { ModeForm } from "./mode-form";
|
||||
export { NetworkForm } from "./network-form";
|
||||
export { PlacementForm } from "./placement-form";
|
||||
export { RestartPolicyForm } from "./restart-policy-form";
|
||||
export { RollbackConfigForm } from "./rollback-config-form";
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const driverOptEntrySchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const networkFormSchema = z.object({
|
||||
networks: z
|
||||
.array(
|
||||
z.object({
|
||||
Target: z.string().optional(),
|
||||
Aliases: z.string().optional(),
|
||||
DriverOptsEntries: z.array(driverOptEntrySchema).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface NetworkFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<z.infer<typeof networkFormSchema>>({
|
||||
resolver: zodResolver(networkFormSchema),
|
||||
defaultValues: {
|
||||
networks: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "networks",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.networkSwarm && Array.isArray(data.networkSwarm)) {
|
||||
const networkEntries = data.networkSwarm.map((network) => ({
|
||||
Target: network.Target || "",
|
||||
Aliases: network.Aliases?.join(", ") || "",
|
||||
DriverOptsEntries: network.DriverOpts
|
||||
? Object.entries(network.DriverOpts).map(([key, value]) => ({
|
||||
key,
|
||||
value: value ?? "",
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
form.reset({ networks: networkEntries });
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: z.infer<typeof networkFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const networksArray =
|
||||
formData.networks
|
||||
?.filter((network) => network.Target)
|
||||
.map((network) => {
|
||||
const entries = (network.DriverOptsEntries ?? []).filter(
|
||||
(e) => e.key.trim() !== "",
|
||||
);
|
||||
const driverOpts =
|
||||
entries.length > 0
|
||||
? Object.fromEntries(
|
||||
entries.map((e) => [e.key.trim(), e.value]),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
Target: network.Target,
|
||||
Aliases: network.Aliases
|
||||
? network.Aliases.split(",").map((alias) => alias.trim())
|
||||
: undefined,
|
||||
DriverOpts: driverOpts,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
// If no networks, send null to clear the database
|
||||
const networksToSend = networksArray.length > 0 ? networksArray : null;
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
networkSwarm: networksToSend,
|
||||
});
|
||||
|
||||
toast.success("Network configuration updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating network configuration");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel>Networks</FormLabel>
|
||||
<FormDescription>
|
||||
Configure network attachments for your service
|
||||
</FormDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="space-y-2 p-3 border rounded">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Target`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Network Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="my-network" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name of the network to attach to
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Aliases`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Aliases (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="alias1, alias2, alias3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated list of network aliases
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Driver options (optional)</FormLabel>
|
||||
<FormDescription>
|
||||
e.g. com.docker.network.driver.mtu,
|
||||
com.docker.network.driver.host_binding
|
||||
</FormDescription>
|
||||
{(
|
||||
form.watch(`networks.${index}.DriverOptsEntries`) ?? []
|
||||
).map((_, optIndex) => (
|
||||
<div
|
||||
key={optIndex}
|
||||
className="flex gap-2 items-end flex-wrap"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[140px]">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="com.docker.network.driver.mtu"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[100px]">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="1500" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
) ?? [];
|
||||
form.setValue(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
entries.filter((_, i) => i !== optIndex),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(`networks.${index}.DriverOptsEntries`) ??
|
||||
[];
|
||||
form.setValue(`networks.${index}.DriverOptsEntries`, [
|
||||
...entries,
|
||||
{ key: "", value: "" },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add driver option
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove Network
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({
|
||||
Target: "",
|
||||
Aliases: "",
|
||||
DriverOptsEntries: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Network
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({ networks: [] });
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Networks
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -21,10 +21,18 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
createConverter,
|
||||
NumberInputWithSteps,
|
||||
} from "@/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -50,13 +58,36 @@ const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
||||
: `${formatNumber(mb)} MB`;
|
||||
});
|
||||
|
||||
const ulimitSchema = z.object({
|
||||
Name: z.string().min(1, "Name is required"),
|
||||
Soft: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
Hard: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
});
|
||||
|
||||
const addResourcesSchema = z.object({
|
||||
memoryReservation: z.string().optional(),
|
||||
cpuLimit: z.string().optional(),
|
||||
memoryLimit: z.string().optional(),
|
||||
cpuReservation: z.string().optional(),
|
||||
ulimitsSwarm: z.array(ulimitSchema).optional(),
|
||||
});
|
||||
|
||||
const ULIMIT_PRESETS = [
|
||||
{ value: "nofile", label: "nofile (Open Files)" },
|
||||
{ value: "nproc", label: "nproc (Processes)" },
|
||||
{ value: "memlock", label: "memlock (Locked Memory)" },
|
||||
{ value: "stack", label: "stack (Stack Size)" },
|
||||
{ value: "core", label: "core (Core File Size)" },
|
||||
{ value: "cpu", label: "cpu (CPU Time)" },
|
||||
{ value: "data", label: "data (Data Segment)" },
|
||||
{ value: "fsize", label: "fsize (File Size)" },
|
||||
{ value: "locks", label: "locks (File Locks)" },
|
||||
{ value: "msgqueue", label: "msgqueue (Message Queues)" },
|
||||
{ value: "nice", label: "nice (Nice Priority)" },
|
||||
{ value: "rtprio", label: "rtprio (Real-time Priority)" },
|
||||
{ value: "sigpending", label: "sigpending (Pending Signals)" },
|
||||
];
|
||||
|
||||
export type ServiceType =
|
||||
| "postgres"
|
||||
| "mongo"
|
||||
@@ -107,10 +138,16 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: "",
|
||||
memoryLimit: "",
|
||||
memoryReservation: "",
|
||||
ulimitsSwarm: [],
|
||||
},
|
||||
resolver: zodResolver(addResourcesSchema),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "ulimitsSwarm",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
@@ -118,6 +155,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: data?.cpuReservation || undefined,
|
||||
memoryLimit: data?.memoryLimit || undefined,
|
||||
memoryReservation: data?.memoryReservation || undefined,
|
||||
ulimitsSwarm: data?.ulimitsSwarm || [],
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
@@ -134,6 +172,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: formData.cpuReservation || null,
|
||||
memoryLimit: formData.memoryLimit || null,
|
||||
memoryReservation: formData.memoryReservation || null,
|
||||
ulimitsSwarm:
|
||||
formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0
|
||||
? formData.ulimitsSwarm
|
||||
: null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Resources Updated");
|
||||
@@ -325,6 +367,145 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ulimits Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>
|
||||
Set resource limits for the container. Each ulimit has
|
||||
a soft limit (warning threshold) and hard limit
|
||||
(maximum allowed). Use -1 for unlimited.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({ Name: "nofile", Soft: 65535, Hard: 65535 })
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Ulimit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Name`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel className="text-xs">Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ulimit" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ULIMIT_PRESETS.map((preset) => (
|
||||
<SelectItem
|
||||
key={preset.value}
|
||||
value={preset.value}
|
||||
>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Soft`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-32">
|
||||
<FormLabel className="text-xs">
|
||||
Soft Limit
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
placeholder="65535"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Hard`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-32">
|
||||
<FormLabel className="text-xs">
|
||||
Hard Limit
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
placeholder="65535"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-6 text-destructive hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No ulimits configured. Click "Add Ulimit" to set
|
||||
resource limits.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const UpdateTraefikConfigSchema = z.object({
|
||||
@@ -59,6 +61,7 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
||||
|
||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -85,13 +88,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
}, [data]);
|
||||
|
||||
const onSubmit = async (data: UpdateTraefikConfig) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
if (!skipYamlValidation) {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
@@ -116,6 +121,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setSkipYamlValidation(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -169,7 +175,28 @@ routers:
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-4">
|
||||
<div className="flex flex-col gap-1 w-full sm:w-auto sm:mr-auto">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="skip-yaml-validation-app"
|
||||
checked={skipYamlValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipYamlValidation(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-yaml-validation-app"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Skip YAML validation (for Go templating)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check to save configs with Go templating (e.g.{" "}
|
||||
<code className="text-xs">{"{{range}}"}</code>).
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-traefik-config"
|
||||
|
||||
@@ -245,13 +245,13 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -263,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!bitbucketId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Bitbucket account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo: GiteaRepository) =>
|
||||
repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -277,11 +277,15 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!giteaId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Gitea account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -251,11 +251,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!githubId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitHub account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -254,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!gitlabId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitLab account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -34,6 +34,7 @@ export const DockerLogs = dynamic(
|
||||
export const badgeStateColor = (state: string) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
case "ready":
|
||||
return "green";
|
||||
case "exited":
|
||||
case "shutdown":
|
||||
@@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -157,6 +159,9 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.currentState
|
||||
? ` ${container.currentState}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
@@ -166,6 +171,13 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{option === "swarm" &&
|
||||
services?.find((c) => c.containerId === containerId)?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
|
||||
<span className="font-medium">Error: </span>
|
||||
{services?.find((c) => c.containerId === containerId)?.error}
|
||||
</div>
|
||||
)}
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./show-patches";
|
||||
export * from "./patch-editor";
|
||||
@@ -0,0 +1,235 @@
|
||||
import { ArrowLeft, ChevronRight, File, Folder, Loader2, Save } from "lucide-react";
|
||||
import { useCallback, 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 type { RouterOutputs } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
applicationId?: string;
|
||||
composeId?: string;
|
||||
repoPath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type DirectoryEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
children?: DirectoryEntry[];
|
||||
};
|
||||
|
||||
export const PatchEditor = ({
|
||||
applicationId,
|
||||
composeId,
|
||||
repoPath,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string>("");
|
||||
const [originalContent, setOriginalContent] = useState<string>("");
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Fetch directory tree
|
||||
const { data: directories, isLoading: isDirLoading } =
|
||||
api.patch.readRepoDirectories.useQuery(
|
||||
{ applicationId, composeId, repoPath },
|
||||
{ enabled: !!repoPath },
|
||||
);
|
||||
|
||||
// Save mutation
|
||||
const saveAsPatch = api.patch.saveFileAsPatch.useMutation({
|
||||
onSuccess: (result) => {
|
||||
setIsSaving(false);
|
||||
if (result.deleted) {
|
||||
toast.success("No changes - patch removed");
|
||||
} else {
|
||||
toast.success("Patch saved");
|
||||
}
|
||||
setOriginalContent(fileContent);
|
||||
},
|
||||
onError: () => {
|
||||
setIsSaving(false);
|
||||
toast.error("Failed to save patch");
|
||||
},
|
||||
});
|
||||
|
||||
// Read file content when selected
|
||||
const { data: fileData, isFetching: isFileLoading } =
|
||||
api.patch.readRepoFile.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
composeId,
|
||||
repoPath,
|
||||
filePath: selectedFile || "",
|
||||
},
|
||||
{
|
||||
enabled: !!selectedFile,
|
||||
onSuccess: (data) => {
|
||||
setFileContent(data.content);
|
||||
setOriginalContent(data.content);
|
||||
if (data.patchError) {
|
||||
toast.error(data.patchErrorMessage || "Failed to apply patch");
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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;
|
||||
setIsSaving(true);
|
||||
saveAsPatch.mutate({
|
||||
applicationId,
|
||||
composeId,
|
||||
repoPath,
|
||||
filePath: selectedFile,
|
||||
content: fileContent,
|
||||
});
|
||||
};
|
||||
|
||||
const hasChanges = fileContent !== originalContent;
|
||||
|
||||
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}>
|
||||
<button
|
||||
onClick={() => toggleFolder(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`}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<Folder className="h-4 w-4 text-blue-500" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
{isExpanded && entry.children && (
|
||||
<div>{renderTree(entry.children, depth + 1)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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" : ""
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 28}px` }}
|
||||
>
|
||||
<File className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
},
|
||||
[expandedFolders, selectedFile],
|
||||
);
|
||||
|
||||
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 && (
|
||||
<Button onClick={handleSave} disabled={isSaving || !hasChanges}>
|
||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Patch
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
|
||||
{/* File Tree */}
|
||||
<div className="border-r h-full overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
{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>
|
||||
{/* Editor */}
|
||||
<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={fileContent}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import { AlertCircle, ChevronRight, File, Folder, Loader2, Power, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
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 type { RouterOutputs } from "@/utils/api";
|
||||
import { PatchEditor } from "./patch-editor";
|
||||
|
||||
interface Props {
|
||||
applicationId?: string;
|
||||
composeId?: string;
|
||||
}
|
||||
|
||||
type Patch = RouterOutputs["patch"]["byApplicationId"][number];
|
||||
|
||||
export const ShowPatches = ({ applicationId, composeId }: Props) => {
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [repoPath, setRepoPath] = useState<string | null>(null);
|
||||
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
// Fetch patches
|
||||
// Fetch patches
|
||||
const { data: appPatches, isLoading: isAppPatchesLoading } =
|
||||
api.patch.byApplicationId.useQuery(
|
||||
{ applicationId: applicationId! },
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { data: composePatches, isLoading: isComposePatchesLoading } =
|
||||
api.patch.byComposeId.useQuery(
|
||||
{ composeId: composeId! },
|
||||
{ enabled: !!composeId },
|
||||
);
|
||||
|
||||
const patches = applicationId ? appPatches : composePatches;
|
||||
const isPatchesLoading = applicationId
|
||||
? isAppPatchesLoading
|
||||
: isComposePatchesLoading;
|
||||
|
||||
// Mutations
|
||||
const deletePatch = api.patch.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Patch deleted");
|
||||
if (applicationId) {
|
||||
utils.patch.byApplicationId.invalidate({ applicationId });
|
||||
} else if (composeId) {
|
||||
utils.patch.byComposeId.invalidate({ composeId });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to delete patch");
|
||||
},
|
||||
});
|
||||
|
||||
const togglePatch = api.patch.toggleEnabled.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Patch updated");
|
||||
if (applicationId) {
|
||||
utils.patch.byApplicationId.invalidate({ applicationId });
|
||||
} else if (composeId) {
|
||||
utils.patch.byComposeId.invalidate({ composeId });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to update patch");
|
||||
},
|
||||
});
|
||||
|
||||
const ensureRepo = api.patch.ensureRepo.useMutation();
|
||||
|
||||
const handleOpenEditor = async () => {
|
||||
setIsLoadingRepo(true);
|
||||
const toastId = toast.loading("Syncing repository...");
|
||||
ensureRepo.mutate(
|
||||
{ applicationId, composeId },
|
||||
{
|
||||
onSuccess: (path) => {
|
||||
setRepoPath(path);
|
||||
setIsLoadingRepo(false);
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
onError: () => {
|
||||
setIsLoadingRepo(false);
|
||||
toast.dismiss(toastId);
|
||||
toast.error("Failed to load repository");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeletePatch = (patchId: string) => {
|
||||
deletePatch.mutate({ patchId });
|
||||
};
|
||||
|
||||
const handleTogglePatch = (patchId: string, enabled: boolean) => {
|
||||
togglePatch.mutate({ patchId, enabled });
|
||||
};
|
||||
|
||||
const handleCloseEditor = () => {
|
||||
setSelectedFile(null);
|
||||
setRepoPath(null);
|
||||
if (applicationId) {
|
||||
utils.patch.byApplicationId.invalidate({ applicationId });
|
||||
} else if (composeId) {
|
||||
utils.patch.byComposeId.invalidate({ composeId });
|
||||
}
|
||||
};
|
||||
|
||||
if (repoPath) {
|
||||
return (
|
||||
<PatchEditor
|
||||
applicationId={applicationId}
|
||||
composeId={composeId}
|
||||
repoPath={repoPath}
|
||||
onClose={handleCloseEditor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
|
||||
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
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 || patches.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>No patches</AlertTitle>
|
||||
<AlertDescription>
|
||||
No patches have been created for this application yet. Click "Create Patch"
|
||||
to add modifications to your code during build.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>File Path</TableHead>
|
||||
<TableHead className="w-[100px]">Enabled</TableHead>
|
||||
<TableHead className="w-[80px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{patches.map((patch: 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" />
|
||||
{patch.filePath}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={patch.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
handleTogglePatch(patch.patchId, checked)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeletePatch(patch.patchId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -247,13 +247,13 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -265,11 +265,15 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!bitbucketId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Bitbucket account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -244,13 +244,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
@@ -261,11 +261,15 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!giteaId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Gitea account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -234,13 +234,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -252,11 +252,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!githubId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitHub account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -256,13 +256,13 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -274,11 +274,15 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!gitlabId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitLab account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -128,6 +128,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -143,6 +144,9 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.currentState
|
||||
? ` ${container.currentState}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
@@ -152,6 +156,13 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{option === "swarm" &&
|
||||
services?.find((c) => c.containerId === containerId)?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
|
||||
<span className="font-medium">Error: </span>
|
||||
{services.find((c) => c.containerId === containerId)?.error}
|
||||
</div>
|
||||
)}
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -93,6 +93,7 @@ export const ShowDockerLogsCompose = ({
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
|
||||
|
||||
@@ -47,6 +49,7 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
|
||||
},
|
||||
);
|
||||
const [canEdit, setCanEdit] = useState(true);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.settings.updateTraefikFile.useMutation();
|
||||
@@ -66,13 +69,15 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
if (!skipYamlValidation) {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
@@ -153,14 +158,37 @@ routers:
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="skip-yaml-validation"
|
||||
checked={skipYamlValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipYamlValidation(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-yaml-validation"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Skip YAML validation (for Go templating)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground -mt-2">
|
||||
Traefik supports Go templating in dynamic configs (e.g.{" "}
|
||||
<code className="text-xs">{"{{range}}"}</code>). Configs using
|
||||
templates will fail standard YAML validation. Check this to save
|
||||
without validation.
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -430,7 +430,7 @@ export const ShowProjects = () => {
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
||||
<span className="flex flex-col gap-1.5 ">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookIcon className="size-4 text-muted-foreground" />
|
||||
@@ -439,7 +439,7 @@ export const ShowProjects = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground break-all">
|
||||
<span className="text-sm font-medium text-muted-foreground break-normal">
|
||||
{project.description}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -39,6 +40,10 @@ const Schema = z.object({
|
||||
giteaUrl: z.string().min(1, {
|
||||
message: "Gitea URL is required",
|
||||
}),
|
||||
giteaInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
clientId: z.string().min(1, {
|
||||
message: "Client ID is required",
|
||||
}),
|
||||
@@ -70,6 +75,7 @@ export const AddGiteaProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -83,6 +89,7 @@ export const AddGiteaProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
});
|
||||
}, [form, webhookUrl, isOpen]);
|
||||
|
||||
@@ -95,6 +102,7 @@ export const AddGiteaProvider = () => {
|
||||
name: data.name,
|
||||
redirectUri: data.redirectUri,
|
||||
giteaUrl: data.giteaUrl,
|
||||
giteaInternalUrl: data.giteaInternalUrl || undefined,
|
||||
organizationName: data.organizationName,
|
||||
})) as unknown as GiteaProviderResponse;
|
||||
|
||||
@@ -223,6 +231,29 @@ export const AddGiteaProvider = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitea:3000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when Gitea runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach Gitea via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUri"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -30,6 +31,10 @@ import { useUrl } from "@/utils/hooks/use-url";
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
giteaUrl: z.string().min(1, "Gitea URL is required"),
|
||||
giteaInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
clientId: z.string().min(1, "Client ID is required"),
|
||||
clientSecret: z.string().min(1, "Client Secret is required"),
|
||||
});
|
||||
@@ -94,6 +99,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
defaultValues: {
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
@@ -104,6 +110,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
form.reset({
|
||||
name: gitea.gitProvider?.name || "",
|
||||
giteaUrl: gitea.giteaUrl || "https://gitea.com",
|
||||
giteaInternalUrl: gitea.giteaInternalUrl || "",
|
||||
clientId: gitea.clientId || "",
|
||||
clientSecret: gitea.clientSecret || "",
|
||||
});
|
||||
@@ -116,6 +123,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
gitProviderId: gitea?.gitProvider?.gitProviderId || "",
|
||||
name: values.name,
|
||||
giteaUrl: values.giteaUrl,
|
||||
giteaInternalUrl: values.giteaInternalUrl ?? null,
|
||||
clientId: values.clientId,
|
||||
clientSecret: values.clientSecret,
|
||||
})
|
||||
@@ -224,6 +232,28 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitea:3000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when Gitea runs on the same instance as Dokploy. Used
|
||||
for OAuth token exchange to reach Gitea via internal network
|
||||
(e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -35,6 +36,10 @@ const Schema = z.object({
|
||||
gitlabUrl: z.string().min(1, {
|
||||
message: "GitLab URL is required",
|
||||
}),
|
||||
gitlabInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
applicationId: z.string().min(1, {
|
||||
message: "Application ID is required",
|
||||
}),
|
||||
@@ -66,6 +71,7 @@ export const AddGitlabProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -80,6 +86,7 @@ export const AddGitlabProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
});
|
||||
}, [form, isOpen]);
|
||||
|
||||
@@ -92,6 +99,7 @@ export const AddGitlabProvider = () => {
|
||||
name: data.name || "",
|
||||
redirectUri: data.redirectUri || "",
|
||||
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
|
||||
gitlabInternalUrl: data.gitlabInternalUrl || undefined,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
@@ -192,6 +200,29 @@ export const AddGitlabProvider = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gitlabInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitlab:80"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when GitLab runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach GitLab via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUri"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -33,6 +34,10 @@ const Schema = z.object({
|
||||
gitlabUrl: z.string().url({
|
||||
message: "Invalid Gitlab URL",
|
||||
}),
|
||||
gitlabInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
groupName: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -61,6 +66,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: "",
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -72,6 +78,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: gitlab?.groupName || "",
|
||||
name: gitlab?.gitProvider.name || "",
|
||||
gitlabUrl: gitlab?.gitlabUrl || "",
|
||||
gitlabInternalUrl: gitlab?.gitlabInternalUrl || "",
|
||||
});
|
||||
}, [form, isOpen]);
|
||||
|
||||
@@ -82,6 +89,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: data.groupName || "",
|
||||
name: data.name || "",
|
||||
gitlabUrl: data.gitlabUrl || "",
|
||||
gitlabInternalUrl: data.gitlabInternalUrl ?? null,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
@@ -151,6 +159,29 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gitlabInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitlab:80"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when GitLab runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach GitLab via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="groupName"
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { Link2, Loader2, Unlink } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const LINKING_CALLBACK_URL = "/dashboard/settings/profile";
|
||||
|
||||
const TRUSTED_PROVIDERS = ["google", "github"] as const;
|
||||
type SocialProvider = (typeof TRUSTED_PROVIDERS)[number];
|
||||
|
||||
type AccountItem = {
|
||||
providerId: string;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
function providerLabel(providerId: string): string {
|
||||
return providerId.charAt(0).toUpperCase() + providerId.slice(1);
|
||||
}
|
||||
|
||||
export function LinkingAccount() {
|
||||
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||
const [accountsLoading, setAccountsLoading] = useState(true);
|
||||
const [linkingProvider, setLinkingProvider] = useState<SocialProvider | null>(
|
||||
null,
|
||||
);
|
||||
const [unlinkingProviderId, setUnlinkingProviderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
setAccountsLoading(true);
|
||||
try {
|
||||
const { data } = await authClient.listAccounts();
|
||||
const list = Array.isArray(data)
|
||||
? data
|
||||
: ((data && typeof data === "object" && "accounts" in data
|
||||
? (data as { accounts?: AccountItem[] }).accounts
|
||||
: null) ?? []);
|
||||
setAccounts(Array.isArray(list) ? list : []);
|
||||
} catch {
|
||||
setAccounts([]);
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts();
|
||||
}, [fetchAccounts]);
|
||||
|
||||
const linkedProviderIds = new Set(accounts.map((a) => a.providerId));
|
||||
const socialAccounts = accounts.filter((a) =>
|
||||
TRUSTED_PROVIDERS.includes(a.providerId as SocialProvider),
|
||||
);
|
||||
|
||||
const handleLinkSocial = async (provider: SocialProvider) => {
|
||||
setLinkingProvider(provider);
|
||||
try {
|
||||
const { error } = await authClient.linkSocial({
|
||||
provider,
|
||||
callbackURL: LINKING_CALLBACK_URL,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to link account");
|
||||
setLinkingProvider(null);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
"Failed to link account",
|
||||
err instanceof Error ? { description: err.message } : undefined,
|
||||
);
|
||||
setLinkingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlink = async (providerId: string, accountId?: string) => {
|
||||
setUnlinkingProviderId(providerId);
|
||||
try {
|
||||
const { error } = await authClient.unlinkAccount({
|
||||
providerId,
|
||||
...(accountId && { accountId }),
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to unlink account");
|
||||
return;
|
||||
}
|
||||
toast.success("Account unlinked");
|
||||
await fetchAccounts();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
"Failed to unlink account",
|
||||
err instanceof Error ? { description: err.message } : undefined,
|
||||
);
|
||||
} finally {
|
||||
setUnlinkingProviderId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canUnlink = accounts.length > 1;
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Link2 className="size-6 text-muted-foreground self-center" />
|
||||
Linking account
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Link your Google or GitHub account to sign in with them.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 py-8 border-t">
|
||||
{/* Linked accounts */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Linked accounts</p>
|
||||
{accountsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : socialAccounts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No social accounts linked yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{socialAccounts.map((acc) => (
|
||||
<li
|
||||
key={acc.accountId ?? acc.providerId}
|
||||
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{providerLabel(acc.providerId)}
|
||||
</span>
|
||||
{canUnlink && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() =>
|
||||
handleUnlink(acc.providerId, acc.accountId)
|
||||
}
|
||||
disabled={unlinkingProviderId === acc.providerId}
|
||||
isLoading={unlinkingProviderId === acc.providerId}
|
||||
>
|
||||
{unlinkingProviderId === acc.providerId ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Unlink className="mr-1.5 size-4" />
|
||||
Unlink
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click a provider below to link it to your account. You will be
|
||||
redirected to complete the flow.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{!linkedProviderIds.has("google") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="min-w-[180px]"
|
||||
onClick={() => handleLinkSocial("google")}
|
||||
disabled={!!linkingProvider}
|
||||
isLoading={linkingProvider === "google"}
|
||||
>
|
||||
{linkingProvider === "google" ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" className="mr-2 size-4">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
Link with Google
|
||||
</Button>
|
||||
)}
|
||||
{!linkedProviderIds.has("github") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="min-w-[180px]"
|
||||
onClick={() => handleLinkSocial("github")}
|
||||
disabled={!!linkingProvider}
|
||||
isLoading={linkingProvider === "github"}
|
||||
>
|
||||
{linkingProvider === "github" ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="mr-2 size-4"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
)}
|
||||
Link with GitHub
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
LarkIcon,
|
||||
NtfyIcon,
|
||||
PushoverIcon,
|
||||
ResendIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
@@ -97,6 +98,23 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
.min(1, { message: "At least one email is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("resend"),
|
||||
apiKey: z.string().min(1, { message: "API Key is required" }),
|
||||
fromAddress: z
|
||||
.string()
|
||||
.min(1, { message: "From Address is required" })
|
||||
.email({ message: "Email is invalid" }),
|
||||
toAddresses: z
|
||||
.array(
|
||||
z.string().min(1, { message: "Email is required" }).email({
|
||||
message: "Email is invalid",
|
||||
}),
|
||||
)
|
||||
.min(1, { message: "At least one email is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("gotify"),
|
||||
@@ -169,6 +187,10 @@ export const notificationsMap = {
|
||||
icon: <Mail size={29} className="text-muted-foreground" />,
|
||||
label: "Email",
|
||||
},
|
||||
resend: {
|
||||
icon: <ResendIcon className="text-muted-foreground" />,
|
||||
label: "Resend",
|
||||
},
|
||||
gotify: {
|
||||
icon: <GotifyIcon />,
|
||||
label: "Gotify",
|
||||
@@ -214,6 +236,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
api.notification.testDiscordConnection.useMutation();
|
||||
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
|
||||
api.notification.testEmailConnection.useMutation();
|
||||
const { mutateAsync: testResendConnection, isLoading: isLoadingResend } =
|
||||
api.notification.testResendConnection.useMutation();
|
||||
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
|
||||
api.notification.testGotifyConnection.useMutation();
|
||||
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
|
||||
@@ -242,6 +266,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
const emailMutation = notificationId
|
||||
? api.notification.updateEmail.useMutation()
|
||||
: api.notification.createEmail.useMutation();
|
||||
const resendMutation = notificationId
|
||||
? api.notification.updateResend.useMutation()
|
||||
: api.notification.createResend.useMutation();
|
||||
const gotifyMutation = notificationId
|
||||
? api.notification.updateGotify.useMutation()
|
||||
: api.notification.createGotify.useMutation();
|
||||
@@ -281,7 +308,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "email" && fields.length === 0) {
|
||||
if ((type === "email" || type === "resend") && fields.length === 0) {
|
||||
append("");
|
||||
}
|
||||
}, [type, append, fields.length]);
|
||||
@@ -349,6 +376,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "resend") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
apiKey: notification.resend?.apiKey,
|
||||
toAddresses: notification.resend?.toAddresses,
|
||||
fromAddress: notification.resend?.fromAddress,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "gotify") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
@@ -442,6 +484,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
telegram: telegramMutation,
|
||||
discord: discordMutation,
|
||||
email: emailMutation,
|
||||
resend: resendMutation,
|
||||
gotify: gotifyMutation,
|
||||
ntfy: ntfyMutation,
|
||||
lark: larkMutation,
|
||||
@@ -525,6 +568,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
emailId: notification?.emailId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "resend") {
|
||||
promise = resendMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
apiKey: data.apiKey,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
notificationId: notificationId || "",
|
||||
resendId: notification?.resendId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "gotify") {
|
||||
promise = gotifyMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
@@ -1042,6 +1101,96 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "resend" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="re_********"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="from@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<FormLabel>To Addresses</FormLabel>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex flex-row gap-2 w-full"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`toAddresses.${index}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="email@example.com"
|
||||
className="w-full"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{type === "resend" &&
|
||||
"toAddresses" in form.formState.errors && (
|
||||
<div className="text-sm font-medium text-destructive">
|
||||
{form.formState?.errors?.toAddresses?.root?.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
append("");
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "gotify" && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -1627,6 +1776,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingTelegram ||
|
||||
isLoadingDiscord ||
|
||||
isLoadingEmail ||
|
||||
isLoadingResend ||
|
||||
isLoadingGotify ||
|
||||
isLoadingNtfy ||
|
||||
isLoadingLark ||
|
||||
@@ -1667,6 +1817,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
});
|
||||
} else if (data.type === "resend") {
|
||||
await testResendConnection({
|
||||
apiKey: data.apiKey,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
});
|
||||
} else if (data.type === "gotify") {
|
||||
await testGotifyConnection({
|
||||
serverUrl: data.serverUrl,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GotifyIcon,
|
||||
LarkIcon,
|
||||
NtfyIcon,
|
||||
ResendIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
@@ -36,7 +37,7 @@ export const ShowNotifications = () => {
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add your providers to receive notifications, like Discord, Slack,
|
||||
Telegram, Email, Lark.
|
||||
Telegram, Email, Resend, Lark.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -86,6 +87,11 @@ export const ShowNotifications = () => {
|
||||
<Mail className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "resend" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<ResendIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "gotify" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<GotifyIcon className="size-6" />
|
||||
|
||||
@@ -23,6 +23,8 @@ export const ShowDokployActions = () => {
|
||||
|
||||
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
|
||||
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
|
||||
const { mutateAsync: cleanAllDeploymentQueue } =
|
||||
api.settings.cleanAllDeploymentQueue.useMutation();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -87,6 +89,21 @@ export const ShowDokployActions = () => {
|
||||
Clean Redis
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
await cleanAllDeploymentQueue()
|
||||
.then(() => {
|
||||
toast.success("Deployment queue cleaned");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error cleaning deployment queue");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clean all deployment queue
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
|
||||
@@ -42,6 +42,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
isLoading: cleanStoppedContainersIsLoading,
|
||||
} = api.settings.cleanStoppedContainers.useMutation();
|
||||
|
||||
const { mutateAsync: cleanPatchRepos, isLoading: cleanPatchReposIsLoading } =
|
||||
api.patch.cleanPatchRepos.useMutation();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -51,7 +54,8 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
cleanDockerBuilderIsLoading ||
|
||||
cleanUnusedImagesIsLoading ||
|
||||
cleanUnusedVolumesIsLoading ||
|
||||
cleanStoppedContainersIsLoading
|
||||
cleanStoppedContainersIsLoading ||
|
||||
cleanPatchReposIsLoading
|
||||
}
|
||||
>
|
||||
<Button
|
||||
@@ -60,7 +64,8 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
cleanDockerBuilderIsLoading ||
|
||||
cleanUnusedImagesIsLoading ||
|
||||
cleanUnusedVolumesIsLoading ||
|
||||
cleanStoppedContainersIsLoading
|
||||
cleanStoppedContainersIsLoading ||
|
||||
cleanPatchReposIsLoading
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
@@ -129,6 +134,23 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onClick={async () => {
|
||||
await cleanPatchRepos({
|
||||
serverId: serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Cleaned Patch Caches");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error cleaning Patch Caches");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean Patch Caches</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onClick={async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import { api } from "@/utils/api";
|
||||
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
|
||||
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
|
||||
@@ -33,14 +34,45 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
serverId,
|
||||
});
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
pollInterval: 4000,
|
||||
successMessage: "Traefik dashboard updated successfully",
|
||||
onSuccess: () => {
|
||||
refetchDashboard();
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
execute: executeReloadWithHealthCheck,
|
||||
isExecuting: isReloadHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
pollInterval: 4000,
|
||||
successMessage: "Traefik Reloaded",
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
disabled={
|
||||
reloadTraefikIsLoading ||
|
||||
toggleDashboardIsLoading ||
|
||||
isHealthCheckExecuting ||
|
||||
isReloadHealthCheckExecuting
|
||||
}
|
||||
>
|
||||
<Button
|
||||
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
isLoading={
|
||||
reloadTraefikIsLoading ||
|
||||
toggleDashboardIsLoading ||
|
||||
isHealthCheckExecuting ||
|
||||
isReloadHealthCheckExecuting
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{t("settings.server.webServer.traefik.label")}
|
||||
@@ -54,15 +86,19 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await reloadTraefik({
|
||||
serverId: serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Traefik Reloaded");
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
await executeReloadWithHealthCheck(() =>
|
||||
reloadTraefik({ serverId }),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
(error as Error)?.message ||
|
||||
"Failed to reload Traefik. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
disabled={isReloadHealthCheckExecuting}
|
||||
>
|
||||
<span>{t("settings.server.webServer.reload")}</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -108,24 +144,21 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
</div>
|
||||
}
|
||||
onClick={async () => {
|
||||
await toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
serverId: serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(
|
||||
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
|
||||
);
|
||||
refetchDashboard();
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
try {
|
||||
await executeWithHealthCheck(() =>
|
||||
toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
serverId: serverId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
(error as Error)?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}}
|
||||
disabled={toggleDashboardIsLoading}
|
||||
disabled={toggleDashboardIsLoading || isHealthCheckExecuting}
|
||||
type="default"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -46,6 +47,14 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.settings.writeTraefikEnv.useMutation();
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
successMessage: "Traefik Env Updated",
|
||||
});
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
env: data || "",
|
||||
@@ -63,16 +72,16 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
await mutateAsync({
|
||||
env: data.env,
|
||||
serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Traefik Env Updated");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the Traefik env");
|
||||
});
|
||||
try {
|
||||
await executeWithHealthCheck(() =>
|
||||
mutateAsync({
|
||||
env: data.env,
|
||||
serverId,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
toast.error("Error updating the Traefik env");
|
||||
}
|
||||
};
|
||||
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
@@ -154,8 +163,8 @@ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_PROVIDER=cloudflare
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
isLoading={isLoading || isHealthCheckExecuting}
|
||||
disabled={canEdit || isLoading || isHealthCheckExecuting}
|
||||
form="hook-form-update-server-traefik-config"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -76,11 +77,19 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
});
|
||||
|
||||
const { mutateAsync: updatePorts, isLoading } =
|
||||
api.settings.updateTraefikPorts.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchPorts();
|
||||
},
|
||||
});
|
||||
api.settings.updateTraefikPorts.useMutation();
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
|
||||
onSuccess: () => {
|
||||
refetchPorts();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPorts) {
|
||||
@@ -99,11 +108,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
|
||||
const onSubmit = async (data: TraefikPortsForm) => {
|
||||
try {
|
||||
await updatePorts({
|
||||
serverId,
|
||||
additionalPorts: data.ports,
|
||||
});
|
||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||
await executeWithHealthCheck(() =>
|
||||
updatePorts({
|
||||
serverId,
|
||||
additionalPorts: data.ports,
|
||||
}),
|
||||
);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "Error updating Traefik ports");
|
||||
@@ -317,7 +327,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="text-sm"
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isHealthCheckExecuting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -257,3 +257,23 @@ export const PushoverIcon = ({ className }: Props) => {
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResendIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn("size-8", className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.12" />
|
||||
<path
|
||||
d="M8 17V7h6a3 3 0 0 1 0 6H8m6 0 2 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,35 +4,21 @@ import { cn } from "@/lib/utils";
|
||||
import { GithubIcon } from "../icons/data-tools-icons";
|
||||
import { Logo } from "../shared/logo";
|
||||
import { Button } from "../ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export const OnboardingLayout = ({ children }: Props) => {
|
||||
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
|
||||
const appName = whitelabel?.whitelabelAppName ?? "Dokploy";
|
||||
const logoUrl =
|
||||
whitelabel?.whitelabelLogoUrl ?? whitelabel?.whitelabelLoginLogoUrl;
|
||||
|
||||
return (
|
||||
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
||||
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
{whitelabel?.whitelabelLoginBackgroundImageUrl && (
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||
style={{
|
||||
backgroundImage: `url(${whitelabel.whitelabelLoginBackgroundImageUrl})`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href="https://dokploy.com"
|
||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
||||
>
|
||||
<Logo className="size-10" logoUrl={logoUrl ?? undefined} />
|
||||
{appName}
|
||||
<Logo className="size-10" />
|
||||
Dokploy
|
||||
</Link>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
LogIn,
|
||||
type LucideIcon,
|
||||
Package,
|
||||
Palette,
|
||||
PieChart,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
@@ -405,8 +404,7 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/license",
|
||||
icon: Key,
|
||||
// Only enabled for admins in non-cloud environments
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -417,15 +415,6 @@ const MENU: Menu = {
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Whitelabeling",
|
||||
url: "/dashboard/settings/whitelabelling",
|
||||
icon: Palette,
|
||||
// Enterprise only – page shows gate if no license
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
],
|
||||
|
||||
help: [
|
||||
@@ -556,7 +545,6 @@ function SidebarLogo() {
|
||||
refetch,
|
||||
isLoading,
|
||||
} = api.organization.all.useQuery();
|
||||
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
|
||||
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
|
||||
api.organization.delete.useMutation();
|
||||
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
|
||||
@@ -622,11 +610,7 @@ function SidebarLogo() {
|
||||
"transition-all",
|
||||
state === "collapsed" ? "size-4" : "size-5",
|
||||
)}
|
||||
logoUrl={
|
||||
activeOrganization?.logo ||
|
||||
whitelabel?.whitelabelLogoUrl ||
|
||||
undefined
|
||||
}
|
||||
logoUrl={activeOrganization?.logo || undefined}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -636,9 +620,7 @@ function SidebarLogo() {
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{activeOrganization?.name ??
|
||||
whitelabel?.whitelabelAppName ??
|
||||
"Select Organization"}
|
||||
{activeOrganization?.name ?? "Select Organization"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,7 +166,12 @@ export function LicenseKeySettings() {
|
||||
{!haveValidLicenseKey && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isSaving || isValidating || isDeactivating}
|
||||
disabled={
|
||||
isSaving ||
|
||||
isValidating ||
|
||||
isDeactivating ||
|
||||
!licenseKey.trim()
|
||||
}
|
||||
isLoading={isActivating}
|
||||
onClick={async () => {
|
||||
try {
|
||||
|
||||
@@ -101,27 +101,32 @@ export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
|
||||
const scopes = data.scopes.filter(Boolean).length
|
||||
? data.scopes.filter(Boolean)
|
||||
: DEFAULT_SCOPES;
|
||||
const domain = data.domains
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
await mutateAsync({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domain,
|
||||
oidcConfig: {
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
scopes,
|
||||
pkce: true,
|
||||
// Keycloak (and many IdPs) send preferred_username; better-auth expects name
|
||||
mapping: {
|
||||
|
||||
const isAzure = data.issuer.includes("login.microsoftonline.com");
|
||||
const mapping = isAzure
|
||||
? {
|
||||
id: "sub",
|
||||
email: "preferred_username",
|
||||
emailVerified: "email_verified",
|
||||
name: "name",
|
||||
}
|
||||
: {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
emailVerified: "email_verified",
|
||||
name: "preferred_username",
|
||||
image: "picture",
|
||||
},
|
||||
};
|
||||
await mutateAsync({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domains: data.domains,
|
||||
oidcConfig: {
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
scopes,
|
||||
pkce: true,
|
||||
mapping,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -52,12 +52,7 @@ const samlProviderSchema = z.object({
|
||||
.url("Invalid URL")
|
||||
.trim(),
|
||||
cert: z.string().min(1, "IdP signing certificate is required"),
|
||||
callbackUrl: z
|
||||
.string()
|
||||
.min(1, "Callback URL is required")
|
||||
.url("Invalid URL")
|
||||
.trim(),
|
||||
audience: z.string().min(1, "Audience (Entity ID) is required").trim(),
|
||||
idpMetadataXml: z.string().optional(),
|
||||
});
|
||||
|
||||
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
|
||||
@@ -72,8 +67,7 @@ const formDefaultValues: SamlProviderForm = {
|
||||
domains: [""],
|
||||
entryPoint: "",
|
||||
cert: "",
|
||||
callbackUrl: "",
|
||||
audience: "",
|
||||
idpMetadataXml: "",
|
||||
};
|
||||
|
||||
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
||||
@@ -81,6 +75,14 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutateAsync, isLoading } = api.sso.register.useMutation();
|
||||
|
||||
const [baseURL, setBaseURL] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setBaseURL(window.location.origin);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const form = useForm<SamlProviderForm>({
|
||||
resolver: zodResolver(samlProviderSchema),
|
||||
defaultValues: formDefaultValues,
|
||||
@@ -95,24 +97,38 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
||||
|
||||
const onSubmit = async (data: SamlProviderForm) => {
|
||||
try {
|
||||
const domain = data.domains
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
.join(",");
|
||||
// maybe add the /saml/metadata endpoint to the baseURL
|
||||
const baseURLWithMetadata = `${baseURL}/saml/metadata`;
|
||||
const generateSpMetadata = (providerId: string) => {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${baseURL}">
|
||||
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${baseURL}/api/auth/sso/saml2/callback/${providerId}" index="1"/>
|
||||
</md:SPSSODescriptor>
|
||||
</md:EntityDescriptor>`;
|
||||
};
|
||||
|
||||
await mutateAsync({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domain,
|
||||
domains: data.domains,
|
||||
samlConfig: {
|
||||
entryPoint: data.entryPoint,
|
||||
cert: data.cert,
|
||||
callbackUrl: data.callbackUrl,
|
||||
audience: data.audience,
|
||||
wantAssertionsSigned: true,
|
||||
signatureAlgorithm: "sha256",
|
||||
digestAlgorithm: "sha256",
|
||||
callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`,
|
||||
audience: baseURL,
|
||||
idpMetadata: data.idpMetadataXml?.trim()
|
||||
? { metadata: data.idpMetadataXml.trim() }
|
||||
: undefined,
|
||||
spMetadata: {
|
||||
entityID: data.audience,
|
||||
metadata: generateSpMetadata(data.providerId),
|
||||
},
|
||||
mapping: {
|
||||
id: "nameID",
|
||||
email: "email",
|
||||
name: "displayName",
|
||||
firstName: "givenName",
|
||||
lastName: "surname",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -268,39 +284,29 @@ export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="callbackUrl"
|
||||
name="idpMetadataXml"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Callback URL (ACS)</FormLabel>
|
||||
<FormLabel>IdP metadata XML (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://yourapp.com/api/auth/sso/saml2/callback/my-provider"
|
||||
<Textarea
|
||||
placeholder="Paste full IdP metadata XML if you have it (EntityDescriptor). Otherwise leave empty and use Issuer, IdP SSO URL and certificate above."
|
||||
rows={5}
|
||||
className="font-mono text-xs"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use the callback URL shown in your IdP app config for this
|
||||
provider.
|
||||
Some IdPs require full metadata; paste the XML here to
|
||||
override issuer/entry point/cert.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="audience"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Audience (Entity ID)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://yourapp.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -340,7 +340,10 @@ export const SSOSettings = () => {
|
||||
Callback URL (configure in your IdP)
|
||||
</span>
|
||||
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||
{baseURL || "{baseURL}"}/api/auth/sso/callback/
|
||||
{baseURL || "{baseURL}"}
|
||||
{detailsProvider.samlConfig
|
||||
? "/api/auth/sso/saml2/callback/"
|
||||
: "/api/auth/sso/callback/"}
|
||||
{detailsProvider.providerId}
|
||||
</p>
|
||||
{!baseURL && (
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2, Palette } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const whitelabelSchema = z.object({
|
||||
whitelabelAppName: z.string().min(1).max(100),
|
||||
whitelabelLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||
whitelabelLoginLogoUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||
whitelabelFaviconUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||
whitelabelLoginTitle: z.string().max(200).optional(),
|
||||
whitelabelLoginSubtitle: z.string().max(500).optional(),
|
||||
whitelabelLoginBackgroundImageUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type WhitelabelFormValues = z.infer<typeof whitelabelSchema>;
|
||||
|
||||
export function WhitelabelSettings() {
|
||||
const { data: settings, isLoading } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const { mutateAsync: updateWhitelabel, isLoading: isSaving } =
|
||||
api.settings.updateWhitelabelSettings.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const form = useForm<WhitelabelFormValues>({
|
||||
resolver: zodResolver(whitelabelSchema),
|
||||
defaultValues: {
|
||||
whitelabelAppName: "Dokploy",
|
||||
whitelabelLogoUrl: "",
|
||||
whitelabelLoginLogoUrl: "",
|
||||
whitelabelFaviconUrl: "",
|
||||
whitelabelLoginTitle: "",
|
||||
whitelabelLoginSubtitle: "",
|
||||
whitelabelLoginBackgroundImageUrl: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
form.reset({
|
||||
whitelabelAppName: settings.whitelabelAppName ?? "Dokploy",
|
||||
whitelabelLogoUrl: settings.whitelabelLogoUrl ?? "",
|
||||
whitelabelLoginLogoUrl: settings.whitelabelLoginLogoUrl ?? "",
|
||||
whitelabelFaviconUrl: settings.whitelabelFaviconUrl ?? "",
|
||||
whitelabelLoginTitle: settings.whitelabelLoginTitle ?? "",
|
||||
whitelabelLoginSubtitle: settings.whitelabelLoginSubtitle ?? "",
|
||||
whitelabelLoginBackgroundImageUrl:
|
||||
settings.whitelabelLoginBackgroundImageUrl ?? "",
|
||||
});
|
||||
}
|
||||
}, [settings, form]);
|
||||
|
||||
const onSubmit = async (values: WhitelabelFormValues) => {
|
||||
try {
|
||||
await updateWhitelabel({
|
||||
whitelabelAppName: values.whitelabelAppName || null,
|
||||
whitelabelLogoUrl: values.whitelabelLogoUrl || undefined,
|
||||
whitelabelLoginLogoUrl: values.whitelabelLoginLogoUrl || undefined,
|
||||
whitelabelFaviconUrl: values.whitelabelFaviconUrl || undefined,
|
||||
whitelabelLoginTitle: values.whitelabelLoginTitle || null,
|
||||
whitelabelLoginSubtitle: values.whitelabelLoginSubtitle || null,
|
||||
whitelabelLoginBackgroundImageUrl:
|
||||
values.whitelabelLoginBackgroundImageUrl || undefined,
|
||||
});
|
||||
toast.success("Whitelabel settings saved");
|
||||
utils.settings.getWebServerSettings.invalidate();
|
||||
utils.settings.getWhitelabelSettings.invalidate();
|
||||
} catch (e) {
|
||||
toast.error("Failed to save whitelabel settings");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Loading whitelabel settings...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg ">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="size-6 text-muted-foreground" />
|
||||
<CardTitle className="text-xl">Whitelabeling</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Customize the application name, logos, and login page for your brand.
|
||||
Leave URLs empty to use defaults.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Brand</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Application name and main logo (sidebar, header).
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelAppName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Application name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Dokploy"
|
||||
{...field}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelLogoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Logo shown in the sidebar and header.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelFaviconUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Favicon URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-6 border-t">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Login page</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize the sign-in and registration screens.
|
||||
</p>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelLoginLogoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login logo URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/login-logo.png"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Logo on the login and register pages. Falls back to the main
|
||||
logo if empty.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelLoginTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login title</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Sign in"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelLoginSubtitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login subtitle</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter your email and password to sign in"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="whitelabelLoginBackgroundImageUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Login background image URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://example.com/background.jpg"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional background image for the login page.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save changes"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
apps/dokploy/drizzle/0137_colossal_sally_floyd.sql
Normal file
18
apps/dokploy/drizzle/0137_colossal_sally_floyd.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "sso_provider" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"oidc_config" text,
|
||||
"saml_config" text,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text,
|
||||
"organization_id" text,
|
||||
"domain" text NOT NULL,
|
||||
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "licenseKey" text;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "trustedOrigins" text[];--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "licenseKey" text;
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE "sso_provider" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"oidc_config" text,
|
||||
"saml_config" text,
|
||||
"user_id" text,
|
||||
"provider_id" text NOT NULL,
|
||||
"organization_id" text,
|
||||
"domain" text NOT NULL,
|
||||
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
10
apps/dokploy/drizzle/0138_pretty_ironclad.sql
Normal file
10
apps/dokploy/drizzle/0138_pretty_ironclad.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TYPE "public"."notificationType" ADD VALUE 'resend' BEFORE 'gotify';--> statement-breakpoint
|
||||
CREATE TABLE "resend" (
|
||||
"resendId" text PRIMARY KEY NOT NULL,
|
||||
"apiKey" text NOT NULL,
|
||||
"fromAddress" text NOT NULL,
|
||||
"toAddress" text[] NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD COLUMN "resendId" text;--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_resendId_resend_resendId_fk" FOREIGN KEY ("resendId") REFERENCES "public"."resend"("resendId") ON DELETE cascade ON UPDATE no action;
|
||||
1
apps/dokploy/drizzle/0139_brave_bloodstorm.sql
Normal file
1
apps/dokploy/drizzle/0139_brave_bloodstorm.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "invitation" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
1
apps/dokploy/drizzle/0140_lame_mattie_franklin.sql
Normal file
1
apps/dokploy/drizzle/0140_lame_mattie_franklin.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "gitlab" ADD COLUMN "gitlabInternalUrl" text;
|
||||
1
apps/dokploy/drizzle/0141_plain_earthquake.sql
Normal file
1
apps/dokploy/drizzle/0141_plain_earthquake.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "gitea" ADD COLUMN "giteaInternalUrl" text;
|
||||
6
apps/dokploy/drizzle/0142_outstanding_tusk.sql
Normal file
6
apps/dokploy/drizzle/0142_outstanding_tusk.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "application" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mariadb" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mongo" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mysql" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "postgres" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "redis" ADD COLUMN "ulimitsSwarm" json;
|
||||
15
apps/dokploy/drizzle/0143_cute_forge.sql
Normal file
15
apps/dokploy/drizzle/0143_cute_forge.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE "patch" (
|
||||
"patchId" text PRIMARY KEY NOT NULL,
|
||||
"filePath" text NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"createdAt" text NOT NULL,
|
||||
"updatedAt" text,
|
||||
"applicationId" text,
|
||||
"composeId" text,
|
||||
CONSTRAINT "patch_filepath_application_unique" UNIQUE("filePath","applicationId"),
|
||||
CONSTRAINT "patch_filepath_compose_unique" UNIQUE("filePath","composeId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "patch" ADD CONSTRAINT "patch_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "patch" ADD CONSTRAINT "patch_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "af1f5881-9a57-4f68-9ef2-632b0370b0c5",
|
||||
"id": "e5c16e66-ec3d-4a91-b3ac-f9ea4577f53f",
|
||||
"prevId": "5958b029-1fb9-4a44-be24-c96b4e899b84",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -6309,6 +6309,102 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sso_provider": {
|
||||
"name": "sso_provider",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"issuer": {
|
||||
"name": "issuer",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oidc_config": {
|
||||
"name": "oidc_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"saml_config": {
|
||||
"name": "saml_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"domain": {
|
||||
"name": "domain",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sso_provider_user_id_user_id_fk": {
|
||||
"name": "sso_provider_user_id_user_id_fk",
|
||||
"tableFrom": "sso_provider",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"sso_provider_organization_id_organization_id_fk": {
|
||||
"name": "sso_provider_organization_id_organization_id_fk",
|
||||
"tableFrom": "sso_provider",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": [
|
||||
"organization_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sso_provider_provider_id_unique": {
|
||||
"name": "sso_provider_provider_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"provider_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
@@ -6441,6 +6537,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"isValidEnterpriseLicense": {
|
||||
"name": "isValidEnterpriseLicense",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"stripeCustomerId": {
|
||||
"name": "stripeCustomerId",
|
||||
"type": "text",
|
||||
@@ -6459,6 +6562,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"trustedOrigins": {
|
||||
"name": "trustedOrigins",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "9192b74d-8589-483e-a188-32d60d18c112",
|
||||
"prevId": "af1f5881-9a57-4f68-9ef2-632b0370b0c5",
|
||||
"id": "45344442-d8aa-48cf-b45f-0c869acbd620",
|
||||
"prevId": "e5c16e66-ec3d-4a91-b3ac-f9ea4577f53f",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -639,89 +639,6 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sso_provider": {
|
||||
"name": "sso_provider",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"issuer": {
|
||||
"name": "issuer",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oidc_config": {
|
||||
"name": "oidc_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"saml_config": {
|
||||
"name": "saml_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"domain": {
|
||||
"name": "domain",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sso_provider_user_id_user_id_fk": {
|
||||
"name": "sso_provider_user_id_user_id_fk",
|
||||
"tableFrom": "sso_provider",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sso_provider_provider_id_unique": {
|
||||
"name": "sso_provider_provider_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"provider_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.two_factor": {
|
||||
"name": "two_factor",
|
||||
"schema": "",
|
||||
@@ -4575,6 +4492,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resendId": {
|
||||
"name": "resendId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"gotifyId": {
|
||||
"name": "gotifyId",
|
||||
"type": "text",
|
||||
@@ -4666,6 +4589,19 @@
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"notification_resendId_resend_resendId_fk": {
|
||||
"name": "notification_resendId_resend_resendId_fk",
|
||||
"tableFrom": "notification",
|
||||
"tableTo": "resend",
|
||||
"columnsFrom": [
|
||||
"resendId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"resendId"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"notification_gotifyId_gotify_gotifyId_fk": {
|
||||
"name": "notification_gotifyId_gotify_gotifyId_fk",
|
||||
"tableFrom": "notification",
|
||||
@@ -4845,6 +4781,43 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.resend": {
|
||||
"name": "resend",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"resendId": {
|
||||
"name": "resendId",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"apiKey": {
|
||||
"name": "apiKey",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fromAddress": {
|
||||
"name": "fromAddress",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"toAddress": {
|
||||
"name": "toAddress",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.slack": {
|
||||
"name": "slack",
|
||||
"schema": "",
|
||||
@@ -6392,6 +6365,102 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sso_provider": {
|
||||
"name": "sso_provider",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"issuer": {
|
||||
"name": "issuer",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oidc_config": {
|
||||
"name": "oidc_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"saml_config": {
|
||||
"name": "saml_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"domain": {
|
||||
"name": "domain",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sso_provider_user_id_user_id_fk": {
|
||||
"name": "sso_provider_user_id_user_id_fk",
|
||||
"tableFrom": "sso_provider",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"sso_provider_organization_id_organization_id_fk": {
|
||||
"name": "sso_provider_organization_id_organization_id_fk",
|
||||
"tableFrom": "sso_provider",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": [
|
||||
"organization_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sso_provider_provider_id_unique": {
|
||||
"name": "sso_provider_provider_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"provider_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
@@ -6524,6 +6593,13 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"isValidEnterpriseLicense": {
|
||||
"name": "isValidEnterpriseLicense",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"stripeCustomerId": {
|
||||
"name": "stripeCustomerId",
|
||||
"type": "text",
|
||||
@@ -6542,6 +6618,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"trustedOrigins": {
|
||||
"name": "trustedOrigins",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -7040,6 +7122,7 @@
|
||||
"telegram",
|
||||
"discord",
|
||||
"email",
|
||||
"resend",
|
||||
"gotify",
|
||||
"ntfy",
|
||||
"pushover",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "4b2adb61-29b2-456d-829f-67faa7c64982",
|
||||
"prevId": "9192b74d-8589-483e-a188-32d60d18c112",
|
||||
"id": "c845d075-eec8-41b2-a3c9-9c63e9425c4b",
|
||||
"prevId": "45344442-d8aa-48cf-b45f-0c869acbd620",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -344,6 +344,13 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -639,89 +646,6 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sso_provider": {
|
||||
"name": "sso_provider",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"issuer": {
|
||||
"name": "issuer",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oidc_config": {
|
||||
"name": "oidc_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"saml_config": {
|
||||
"name": "saml_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"domain": {
|
||||
"name": "domain",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sso_provider_user_id_user_id_fk": {
|
||||
"name": "sso_provider_user_id_user_id_fk",
|
||||
"tableFrom": "sso_provider",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sso_provider_provider_id_unique": {
|
||||
"name": "sso_provider_provider_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"provider_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.two_factor": {
|
||||
"name": "two_factor",
|
||||
"schema": "",
|
||||
@@ -4575,6 +4499,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resendId": {
|
||||
"name": "resendId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"gotifyId": {
|
||||
"name": "gotifyId",
|
||||
"type": "text",
|
||||
@@ -4666,6 +4596,19 @@
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"notification_resendId_resend_resendId_fk": {
|
||||
"name": "notification_resendId_resend_resendId_fk",
|
||||
"tableFrom": "notification",
|
||||
"tableTo": "resend",
|
||||
"columnsFrom": [
|
||||
"resendId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"resendId"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"notification_gotifyId_gotify_gotifyId_fk": {
|
||||
"name": "notification_gotifyId_gotify_gotifyId_fk",
|
||||
"tableFrom": "notification",
|
||||
@@ -4845,6 +4788,43 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.resend": {
|
||||
"name": "resend",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"resendId": {
|
||||
"name": "resendId",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"apiKey": {
|
||||
"name": "apiKey",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fromAddress": {
|
||||
"name": "fromAddress",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"toAddress": {
|
||||
"name": "toAddress",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.slack": {
|
||||
"name": "slack",
|
||||
"schema": "",
|
||||
@@ -6392,6 +6372,102 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.sso_provider": {
|
||||
"name": "sso_provider",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"issuer": {
|
||||
"name": "issuer",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"oidc_config": {
|
||||
"name": "oidc_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"saml_config": {
|
||||
"name": "saml_config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"domain": {
|
||||
"name": "domain",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sso_provider_user_id_user_id_fk": {
|
||||
"name": "sso_provider_user_id_user_id_fk",
|
||||
"tableFrom": "sso_provider",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"sso_provider_organization_id_organization_id_fk": {
|
||||
"name": "sso_provider_organization_id_organization_id_fk",
|
||||
"tableFrom": "sso_provider",
|
||||
"tableTo": "organization",
|
||||
"columnsFrom": [
|
||||
"organization_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sso_provider_provider_id_unique": {
|
||||
"name": "sso_provider_provider_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"provider_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
@@ -6549,6 +6625,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"trustedOrigins": {
|
||||
"name": "trustedOrigins",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -7047,6 +7129,7 @@
|
||||
"telegram",
|
||||
"discord",
|
||||
"email",
|
||||
"resend",
|
||||
"gotify",
|
||||
"ntfy",
|
||||
"pushover",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "2d5967fa-7d7c-4efe-b573-02c14983be02",
|
||||
"prevId": "4b2adb61-29b2-456d-829f-67faa7c64982",
|
||||
"id": "9cf69a2e-b38d-4ed5-ad01-27065deac7f6",
|
||||
"prevId": "c845d075-eec8-41b2-a3c9-9c63e9425c4b",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -344,6 +344,13 @@
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -3211,6 +3218,12 @@
|
||||
"notNull": true,
|
||||
"default": "'https://gitlab.com'"
|
||||
},
|
||||
"gitlabInternalUrl": {
|
||||
"name": "gitlabInternalUrl",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"application_id": {
|
||||
"name": "application_id",
|
||||
"type": "text",
|
||||
@@ -4492,6 +4505,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"resendId": {
|
||||
"name": "resendId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"gotifyId": {
|
||||
"name": "gotifyId",
|
||||
"type": "text",
|
||||
@@ -4583,6 +4602,19 @@
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"notification_resendId_resend_resendId_fk": {
|
||||
"name": "notification_resendId_resend_resendId_fk",
|
||||
"tableFrom": "notification",
|
||||
"tableTo": "resend",
|
||||
"columnsFrom": [
|
||||
"resendId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"resendId"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"notification_gotifyId_gotify_gotifyId_fk": {
|
||||
"name": "notification_gotifyId_gotify_gotifyId_fk",
|
||||
"tableFrom": "notification",
|
||||
@@ -4762,6 +4794,43 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.resend": {
|
||||
"name": "resend",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"resendId": {
|
||||
"name": "resendId",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"apiKey": {
|
||||
"name": "apiKey",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"fromAddress": {
|
||||
"name": "fromAddress",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"toAddress": {
|
||||
"name": "toAddress",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.slack": {
|
||||
"name": "slack",
|
||||
"schema": "",
|
||||
@@ -6562,6 +6631,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"trustedOrigins": {
|
||||
"name": "trustedOrigins",
|
||||
"type": "text[]",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@@ -7060,6 +7135,7 @@
|
||||
"telegram",
|
||||
"discord",
|
||||
"email",
|
||||
"resend",
|
||||
"gotify",
|
||||
"ntfy",
|
||||
"pushover",
|
||||
|
||||
7248
apps/dokploy/drizzle/meta/0141_snapshot.json
Normal file
7248
apps/dokploy/drizzle/meta/0141_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7284
apps/dokploy/drizzle/meta/0142_snapshot.json
Normal file
7284
apps/dokploy/drizzle/meta/0142_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7390
apps/dokploy/drizzle/meta/0143_snapshot.json
Normal file
7390
apps/dokploy/drizzle/meta/0143_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -964,29 +964,50 @@
|
||||
{
|
||||
"idx": 137,
|
||||
"version": "7",
|
||||
"when": 1769616589728,
|
||||
"tag": "0137_naive_power_pack",
|
||||
"when": 1770274109332,
|
||||
"tag": "0137_colossal_sally_floyd",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 138,
|
||||
"version": "7",
|
||||
"when": 1769745328628,
|
||||
"tag": "0138_common_mathemanic",
|
||||
"when": 1770324882572,
|
||||
"tag": "0138_pretty_ironclad",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 139,
|
||||
"version": "7",
|
||||
"when": 1769746948088,
|
||||
"tag": "0139_smiling_havok",
|
||||
"when": 1770442690721,
|
||||
"tag": "0139_brave_bloodstorm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 140,
|
||||
"version": "7",
|
||||
"when": 1769854977685,
|
||||
"tag": "0140_great_lightspeed",
|
||||
"when": 1770489900075,
|
||||
"tag": "0140_lame_mattie_franklin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 141,
|
||||
"version": "7",
|
||||
"when": 1770490719123,
|
||||
"tag": "0141_plain_earthquake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 142,
|
||||
"version": "7",
|
||||
"when": 1770615019498,
|
||||
"tag": "0142_outstanding_tusk",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 143,
|
||||
"version": "7",
|
||||
"when": 1770756316554,
|
||||
"tag": "0143_cute_forge",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -24,6 +24,8 @@ try {
|
||||
.build({
|
||||
entryPoints: {
|
||||
server: "server/server.ts",
|
||||
migration: "migration.ts",
|
||||
"wait-for-postgres": "wait-for-postgres.ts",
|
||||
"reset-password": "reset-password.ts",
|
||||
"reset-2fa": "reset-2fa.ts",
|
||||
},
|
||||
|
||||
92
apps/dokploy/hooks/use-health-check-after-mutation.ts
Normal file
92
apps/dokploy/hooks/use-health-check-after-mutation.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const HEALTH_CHECK_URL = "/api/health";
|
||||
|
||||
export interface UseHealthCheckAfterMutationOptions {
|
||||
/**
|
||||
* Delay in ms before starting to poll the health endpoint.
|
||||
* Gives time for the service (e.g. Traefik) to restart.
|
||||
* @default 5000
|
||||
*/
|
||||
initialDelay?: number;
|
||||
/**
|
||||
* Delay in ms between each health check poll.
|
||||
* @default 2000
|
||||
*/
|
||||
pollInterval?: number;
|
||||
/**
|
||||
* Message shown in toast when the operation completes successfully.
|
||||
*/
|
||||
successMessage: string;
|
||||
/**
|
||||
* Callback when health check passes. Use for refetching data.
|
||||
*/
|
||||
onSuccess?: () => void | Promise<void>;
|
||||
/**
|
||||
* If true, reloads the page when health check passes (e.g. for server update).
|
||||
* @default false
|
||||
*/
|
||||
reloadOnSuccess?: boolean;
|
||||
}
|
||||
|
||||
export const useHealthCheckAfterMutation = ({
|
||||
initialDelay = 5000,
|
||||
pollInterval = 2000,
|
||||
successMessage,
|
||||
onSuccess,
|
||||
reloadOnSuccess = false,
|
||||
}: UseHealthCheckAfterMutationOptions) => {
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
|
||||
const checkHealth = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(HEALTH_CHECK_URL);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pollUntilHealthy = useCallback(async (): Promise<void> => {
|
||||
const isHealthy = await checkHealth();
|
||||
|
||||
if (isHealthy) {
|
||||
toast.success(successMessage);
|
||||
|
||||
if (reloadOnSuccess) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
await onSuccess?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
await pollUntilHealthy();
|
||||
}, [checkHealth, successMessage, reloadOnSuccess, onSuccess, pollInterval]);
|
||||
|
||||
const execute = useCallback(
|
||||
async <T>(mutationFn: () => Promise<T>): Promise<T> => {
|
||||
setIsExecuting(true);
|
||||
|
||||
try {
|
||||
const result = await mutationFn();
|
||||
|
||||
// Give time for the service to restart before polling
|
||||
await new Promise((resolve) => setTimeout(resolve, initialDelay));
|
||||
|
||||
await pollUntilHealthy();
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
},
|
||||
[initialDelay, pollUntilHealthy],
|
||||
);
|
||||
|
||||
return { execute, isExecuting };
|
||||
};
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.26.7",
|
||||
"version": "v0.27.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build-server && npm run build-next",
|
||||
"start": "node -r dotenv/config dist/server.mjs",
|
||||
"start": "node -r dotenv/config dist/migration.mjs && node -r dotenv/config dist/server.mjs",
|
||||
"build-server": "tsx esbuild.config.ts",
|
||||
"build-next": "next build",
|
||||
"build-next": "next build --webpack",
|
||||
"setup": "tsx -r dotenv/config setup.ts && sleep 5 && pnpm run migration:run",
|
||||
"wait-for-postgres": "node -r dotenv/config dist/wait-for-postgres.mjs",
|
||||
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
|
||||
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
||||
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
|
||||
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
||||
@@ -37,6 +39,7 @@
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"resend": "^6.0.2",
|
||||
"@better-auth/sso": "1.4.18",
|
||||
"@ai-sdk/anthropic": "^2.0.5",
|
||||
"@ai-sdk/azure": "^2.0.16",
|
||||
@@ -98,7 +101,7 @@
|
||||
"better-auth": "1.4.18",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.4.2",
|
||||
"bullmq": "5.67.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -107,7 +110,7 @@
|
||||
"date-fns": "3.6.0",
|
||||
"dockerode": "4.0.2",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"fancy-ansi": "^0.1.3",
|
||||
"i18next": "^23.16.8",
|
||||
@@ -117,7 +120,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "^16.0.10",
|
||||
"next": "^16.1.6",
|
||||
"next-i18next": "^15.4.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
@@ -165,7 +168,7 @@
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^18.19.104",
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -175,7 +178,7 @@
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
"@types/ws": "8.5.10",
|
||||
"autoprefixer": "10.4.12",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"esbuild": "0.20.2",
|
||||
"lint-staged": "^15.5.2",
|
||||
"memfs": "^4.17.2",
|
||||
@@ -183,7 +186,7 @@
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-tsconfig-paths": "4.3.2",
|
||||
"vitest": "^1.6.1"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.25.2"
|
||||
|
||||
@@ -355,6 +355,11 @@ export default async function handler(
|
||||
action === "labeled" ||
|
||||
action === "unlabeled"
|
||||
) {
|
||||
const shouldCreateDeployment =
|
||||
action === "opened" ||
|
||||
action === "synchronize" ||
|
||||
action === "reopened";
|
||||
|
||||
const repository = githubBody?.repository?.name;
|
||||
const deploymentHash = githubBody?.pull_request?.head?.sha;
|
||||
const branch = githubBody?.pull_request?.base?.ref;
|
||||
@@ -475,7 +480,7 @@ export default async function handler(
|
||||
let previewDeploymentId =
|
||||
previewDeploymentResult?.previewDeploymentId || "";
|
||||
|
||||
if (!previewDeploymentResult) {
|
||||
if (!previewDeploymentResult && shouldCreateDeployment) {
|
||||
const previewDeployment = await createPreviewDeployment({
|
||||
applicationId: app.applicationId as string,
|
||||
branch: prBranch,
|
||||
@@ -497,21 +502,23 @@ export default async function handler(
|
||||
previewDeploymentId,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && app.serverId) {
|
||||
jobData.serverId = app.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
continue;
|
||||
if (previewDeploymentId) {
|
||||
if (IS_CLOUD && app.serverId) {
|
||||
jobData.serverId = app.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
return res.status(200).json({ message: "Apps Deployed" });
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ const parseState = (state: string): string | null => {
|
||||
|
||||
// Helper to fetch access token from Gitea
|
||||
const fetchAccessToken = async (gitea: Gitea, code: string) => {
|
||||
const response = await fetch(`${gitea.giteaUrl}/login/oauth/access_token`, {
|
||||
// Use internal URL for token exchange when Gitea is on same instance as Dokploy
|
||||
const baseUrl = gitea.giteaInternalUrl || gitea.giteaUrl;
|
||||
const response = await fetch(`${baseUrl}/login/oauth/access_token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface Gitea {
|
||||
refreshToken: string | null;
|
||||
expiresAt: number | null;
|
||||
giteaUrl: string;
|
||||
giteaInternalUrl: string | null;
|
||||
clientId: string | null;
|
||||
clientSecret: string | null;
|
||||
organizationName?: string;
|
||||
|
||||
@@ -12,7 +12,9 @@ export default async function handler(
|
||||
}
|
||||
|
||||
const gitlab = await findGitlabById(gitlabId as string);
|
||||
const gitlabUrl = new URL(gitlab.gitlabUrl);
|
||||
// Use internal URL for token exchange when GitLab is on same instance as Dokploy
|
||||
const baseUrl = gitlab.gitlabInternalUrl || gitlab.gitlabUrl;
|
||||
const gitlabUrl = new URL(baseUrl);
|
||||
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ShowPreviewDeployments } from "@/components/dashboard/application/previ
|
||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||
import { UpdateApplication } from "@/components/dashboard/application/update-application";
|
||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
|
||||
@@ -248,6 +249,9 @@ const Service = (
|
||||
Volume Backups
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||
{data?.sourceType !== "docker" && (
|
||||
<TabsTrigger value="patches">Patches</TabsTrigger>
|
||||
)}
|
||||
{((data?.serverId && isCloud) || !data?.server) && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
)}
|
||||
@@ -359,6 +363,11 @@ const Service = (
|
||||
<ShowDomains id={applicationId} type="application" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="patches" className="w-full">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowPatches applicationId={applicationId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="advanced">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<AddCommand applicationId={applicationId} />
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ShowDomains } from "@/components/dashboard/application/domains/show-dom
|
||||
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
|
||||
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
|
||||
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
|
||||
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
|
||||
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
|
||||
import { IsolatedDeploymentTab } from "@/components/dashboard/compose/advanced/add-isolation";
|
||||
import { DeleteService } from "@/components/dashboard/compose/delete-service";
|
||||
@@ -237,6 +238,9 @@ const Service = (
|
||||
Volume Backups
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||
{data?.sourceType !== "raw" && (
|
||||
<TabsTrigger value="patches">Patches</TabsTrigger>
|
||||
)}
|
||||
{((data?.serverId && isCloud) || !data?.server) && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
)}
|
||||
@@ -361,6 +365,12 @@ const Service = (
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="patches" className="w-full">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<ShowPatches composeId={composeId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced">
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<AddCommandCompose composeId={composeId} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IS_CLOUD, validateRequest } from "@dokploy/server";
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
@@ -36,14 +36,6 @@ export async function getServerSideProps(
|
||||
) {
|
||||
const { req, res } = ctx;
|
||||
const locale = await getLocale(req.cookies);
|
||||
if (IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/projects",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
@@ -53,7 +45,7 @@ export async function getServerSideProps(
|
||||
},
|
||||
};
|
||||
}
|
||||
if (user.role === "member") {
|
||||
if (user.role !== "owner") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys";
|
||||
import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account";
|
||||
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
@@ -12,17 +13,16 @@ import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||
|
||||
const Page = () => {
|
||||
const { data } = api.user.get.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
// const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<ProfileForm />
|
||||
{isCloud && <LinkingAccount />}
|
||||
{(data?.canAccessToAPI ||
|
||||
data?.role === "owner" ||
|
||||
data?.role === "admin") && <ShowApiKeys />}
|
||||
|
||||
{/* {isCloud && <RemoveSelfAccount />} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
|
||||
import { WhitelabelSettings } from "@/components/proprietary/whitelabelling/whitelabel-settings";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<div className="p-6">
|
||||
<EnterpriseFeatureGate
|
||||
lockedProps={{
|
||||
title: "Enterprise Whitelabeling",
|
||||
description:
|
||||
"Whitelabeling is part of Dokploy Enterprise. Add a valid license to customize logos, app name, and login page.",
|
||||
ctaLabel: "Go to License",
|
||||
}}
|
||||
>
|
||||
<WhitelabelSettings />
|
||||
</EnterpriseFeatureGate>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Whitelabeling">{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const { req } = ctx;
|
||||
const locale = await getLocale(req.cookies);
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (user.role === "member") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/dashboard/settings/profile",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: ctx.res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.user.get.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
...(await serverSideTranslations(locale, ["settings"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -59,7 +59,6 @@ interface Props {
|
||||
export default function Home({ IS_CLOUD }: Props) {
|
||||
const router = useRouter();
|
||||
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
|
||||
const { data: whitelabel } = api.settings.getWhitelabelSettings.useQuery();
|
||||
const [isLoginLoading, setIsLoginLoading] = useState(false);
|
||||
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
|
||||
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
|
||||
@@ -213,27 +212,17 @@ export default function Home({ IS_CLOUD }: Props) {
|
||||
</>
|
||||
);
|
||||
|
||||
const loginLogoUrl =
|
||||
whitelabel?.whitelabelLoginLogoUrl ?? whitelabel?.whitelabelLogoUrl;
|
||||
const loginTitle = whitelabel?.whitelabelLoginTitle ?? "Sign in";
|
||||
const loginSubtitle =
|
||||
whitelabel?.whitelabelLoginSubtitle ??
|
||||
"Enter your email and password to sign in";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={loginLogoUrl ?? undefined}
|
||||
/>
|
||||
{loginTitle}
|
||||
<Logo className="size-12" />
|
||||
Sign in
|
||||
</div>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loginSubtitle}
|
||||
Enter your email and password to sign in
|
||||
</p>
|
||||
</div>
|
||||
{error && (
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function Home() {
|
||||
|
||||
const onSubmit = async (values: Login) => {
|
||||
setIsLoading(true);
|
||||
const { error } = await authClient.forgetPassword({
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
email: values.email,
|
||||
redirectTo: "/reset-password",
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 4.6 KiB |
@@ -22,6 +22,7 @@ import { mountRouter } from "./routers/mount";
|
||||
import { mysqlRouter } from "./routers/mysql";
|
||||
import { notificationRouter } from "./routers/notification";
|
||||
import { organizationRouter } from "./routers/organization";
|
||||
import { patchRouter } from "./routers/patch";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { portRouter } from "./routers/port";
|
||||
@@ -90,6 +91,7 @@ export const appRouter = createTRPCRouter({
|
||||
rollback: rollbackRouter,
|
||||
volumeBackups: volumeBackupsRouter,
|
||||
environment: environmentRouter,
|
||||
patch: patchRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -57,9 +57,11 @@ import {
|
||||
apiUpdateApplication,
|
||||
applications,
|
||||
} from "@/server/db/schema";
|
||||
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import {
|
||||
cleanQueuesByApplication,
|
||||
getJobsByApplicationId,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
@@ -240,6 +242,15 @@ export const applicationRouter = createTRPCRouter({
|
||||
.where(eq(applications.applicationId, input.applicationId))
|
||||
.returning();
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
const queueJobs = await getJobsByApplicationId(input.applicationId);
|
||||
for (const job of queueJobs) {
|
||||
if (job.id) {
|
||||
deploymentWorker.cancelJob(job.id, "User requested cancellation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupOperations = [
|
||||
async () => await deleteAllMiddlewares(application),
|
||||
async () => await removeDeployments(application),
|
||||
|
||||
@@ -58,9 +58,11 @@ import {
|
||||
apiUpdateCompose,
|
||||
compose as composeTable,
|
||||
} from "@/server/db/schema";
|
||||
import { deploymentWorker } from "@/server/queues/deployments-queue";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import {
|
||||
cleanQueuesByCompose,
|
||||
getJobsByComposeId,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
@@ -222,6 +224,15 @@ export const composeRouter = createTRPCRouter({
|
||||
.where(eq(composeTable.composeId, input.composeId))
|
||||
.returning();
|
||||
|
||||
if (!IS_CLOUD) {
|
||||
const queueJobs = await getJobsByComposeId(input.composeId);
|
||||
for (const job of queueJobs) {
|
||||
if (job.id) {
|
||||
deploymentWorker.cancelJob(job.id, "User requested cancellation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanupOperations = [
|
||||
async () => await removeCompose(composeResult, input.deleteVolumes),
|
||||
async () => await removeDeploymentsByComposeId(composeResult),
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
findDomainById,
|
||||
findDomainsByApplicationId,
|
||||
findDomainsByComposeId,
|
||||
findOrganizationById,
|
||||
findPreviewDeploymentById,
|
||||
findServerById,
|
||||
generateTraefikMeDomain,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
addNewService,
|
||||
checkPortInUse,
|
||||
checkServiceAccess,
|
||||
createMariadb,
|
||||
createMount,
|
||||
@@ -162,9 +163,9 @@ export const mariadbRouter = createTRPCRouter({
|
||||
saveExternalPort: protectedProcedure
|
||||
.input(apiSaveExternalPortMariaDB)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mongo = await findMariadbById(input.mariadbId);
|
||||
const mariadb = await findMariadbById(input.mariadbId);
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
mariadb.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
@@ -172,11 +173,25 @@ export const mariadbRouter = createTRPCRouter({
|
||||
message: "You are not authorized to save this external port",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.externalPort) {
|
||||
const portCheck = await checkPortInUse(
|
||||
input.externalPort,
|
||||
mariadb.serverId || undefined,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await updateMariadbById(input.mariadbId, {
|
||||
externalPort: input.externalPort,
|
||||
});
|
||||
await deployMariadb(input.mariadbId);
|
||||
return mongo;
|
||||
return mariadb;
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
.input(apiDeployMariaDB)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
addNewService,
|
||||
checkPortInUse,
|
||||
checkServiceAccess,
|
||||
createMongo,
|
||||
createMount,
|
||||
@@ -189,6 +190,20 @@ export const mongoRouter = createTRPCRouter({
|
||||
message: "You are not authorized to save this external port",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.externalPort) {
|
||||
const portCheck = await checkPortInUse(
|
||||
input.externalPort,
|
||||
mongo.serverId || undefined,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await updateMongoById(input.mongoId, {
|
||||
externalPort: input.externalPort,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
addNewService,
|
||||
checkPortInUse,
|
||||
checkServiceAccess,
|
||||
createMount,
|
||||
createMysql,
|
||||
@@ -177,9 +178,9 @@ export const mysqlRouter = createTRPCRouter({
|
||||
saveExternalPort: protectedProcedure
|
||||
.input(apiSaveExternalPortMySql)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const mongo = await findMySqlById(input.mysqlId);
|
||||
const mysql = await findMySqlById(input.mysqlId);
|
||||
if (
|
||||
mongo.environment.project.organizationId !==
|
||||
mysql.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
@@ -187,11 +188,25 @@ export const mysqlRouter = createTRPCRouter({
|
||||
message: "You are not authorized to save this external port",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.externalPort) {
|
||||
const portCheck = await checkPortInUse(
|
||||
input.externalPort,
|
||||
mysql.serverId || undefined,
|
||||
);
|
||||
if (portCheck.isInUse) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `Port ${input.externalPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await updateMySqlById(input.mysqlId, {
|
||||
externalPort: input.externalPort,
|
||||
});
|
||||
await deployMySql(input.mysqlId);
|
||||
return mongo;
|
||||
return mysql;
|
||||
}),
|
||||
deploy: protectedProcedure
|
||||
.input(apiDeployMySql)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user