mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-20 13:25:32 +02:00
Compare commits
218 Commits
v0.25.6
...
2326-add-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e052850b87 | ||
|
|
e06f5979c3 | ||
|
|
6b346d30ee | ||
|
|
9e98f9ce7f | ||
|
|
c8e7aae5c6 | ||
|
|
75a49790ea | ||
|
|
716e8b351f | ||
|
|
e993955f5a | ||
|
|
caf0aa6a12 | ||
|
|
21eb185431 | ||
|
|
bb3f73851a | ||
|
|
40949f2a8f | ||
|
|
fe7a73baee | ||
|
|
b1505651c2 | ||
|
|
689c689487 | ||
|
|
1aac5c1670 | ||
|
|
ea83406f6f | ||
|
|
25aecab062 | ||
|
|
9e11b802fd | ||
|
|
adfe29e10c | ||
|
|
c1d23b18fb | ||
|
|
272a8dbdb2 | ||
|
|
dc4e8ecdc9 | ||
|
|
559753eae3 | ||
|
|
2d0669e288 | ||
|
|
3f12f20e4c | ||
|
|
4907a021a4 | ||
|
|
817825e8bd | ||
|
|
0f632e3f55 | ||
|
|
8728d4b600 | ||
|
|
88b4374019 | ||
|
|
b91cb6cb5e | ||
|
|
c8277f6573 | ||
|
|
24c216e61a | ||
|
|
5c630e7ad7 | ||
|
|
c0dec0ed20 | ||
|
|
7d9806a050 | ||
|
|
96e7b39e3c | ||
|
|
ded16f39af | ||
|
|
d8e521e4dc | ||
|
|
67643fe088 | ||
|
|
aab982b431 | ||
|
|
362416afa8 | ||
|
|
035f8835cf | ||
|
|
8cff84ef54 | ||
|
|
742ca00d3d | ||
|
|
3481da9b0e | ||
|
|
15634c9f10 | ||
|
|
704582f6de | ||
|
|
65d962efc8 | ||
|
|
78d2e13dc8 | ||
|
|
28f7fb90c0 | ||
|
|
8647e7a6b7 | ||
|
|
cc1620b5fa | ||
|
|
27b605f961 | ||
|
|
a72281c018 | ||
|
|
aa750be036 | ||
|
|
067777f28e | ||
|
|
f77a67ba33 | ||
|
|
30d2f38259 | ||
|
|
b23ba17a41 | ||
|
|
218c077255 | ||
|
|
f94d5b9582 | ||
|
|
b9d05b00a9 | ||
|
|
f61fb3aba0 | ||
|
|
d3b7e68da9 | ||
|
|
061ca6c95c | ||
|
|
e576c1a63f | ||
|
|
5d53cf4090 | ||
|
|
ff27f0828b | ||
|
|
33d4f57611 | ||
|
|
bacadccaa9 | ||
|
|
55748749fd | ||
|
|
45b75fdfde | ||
|
|
ff822481c5 | ||
|
|
783324628f | ||
|
|
e70c476c9f | ||
|
|
891260fe41 | ||
|
|
062037a9e6 | ||
|
|
7da1be877b | ||
|
|
60e6285e8e | ||
|
|
cd8c67bb9b | ||
|
|
4fb3ad3032 | ||
|
|
736a7320d4 | ||
|
|
23b235303c | ||
|
|
eb8c6e4367 | ||
|
|
965f05c7c8 | ||
|
|
e316beaddb | ||
|
|
8aff1e7614 | ||
|
|
dbe1733dcb | ||
|
|
73d87c06e1 | ||
|
|
e136934cbc | ||
|
|
4840abe3a4 | ||
|
|
f046ba427a | ||
|
|
b12e84c645 | ||
|
|
d18fe8390b | ||
|
|
e88a9ce96f | ||
|
|
1c652477fb | ||
|
|
a5abd46386 | ||
|
|
ad0e044740 | ||
|
|
7a0ff72f51 | ||
|
|
2e702dc41f | ||
|
|
766f9244da | ||
|
|
6413fa54e6 | ||
|
|
1c9dcc0c9e | ||
|
|
fee802a57b | ||
|
|
af2b053caa | ||
|
|
42a4cc7fff | ||
|
|
2a7807c2b3 | ||
|
|
153390ff26 | ||
|
|
425b8ec3c2 | ||
|
|
e86caccfd5 | ||
|
|
8a93116ce0 | ||
|
|
daff2adb02 | ||
|
|
052fc5ffe1 | ||
|
|
96dff0c1bb | ||
|
|
f53e1a6543 | ||
|
|
9e2788e764 | ||
|
|
4884ee3352 | ||
|
|
82cfe06fa4 | ||
|
|
a79afe49b4 | ||
|
|
19a01665ae | ||
|
|
48503c96c1 | ||
|
|
398300f729 | ||
|
|
d08fdeb939 | ||
|
|
8ca8839d7e | ||
|
|
605de97805 | ||
|
|
6ba35057ac | ||
|
|
46d1809f84 | ||
|
|
ba5e7e2026 | ||
|
|
8a741e41bb | ||
|
|
1581defc39 | ||
|
|
f5891b8793 | ||
|
|
8b13919d3b | ||
|
|
19244a2dea | ||
|
|
c4c1930195 | ||
|
|
b2264a9148 | ||
|
|
f7ddc715c7 | ||
|
|
3a17c9b9e8 | ||
|
|
201cc65b09 | ||
|
|
3618be65fc | ||
|
|
e9b4245625 | ||
|
|
e60c68dbeb | ||
|
|
f46444e039 | ||
|
|
05e3d241f1 | ||
|
|
5c2bae2f21 | ||
|
|
d854979fe3 | ||
|
|
8016708798 | ||
|
|
09a98a29e0 | ||
|
|
a4caa47e10 | ||
|
|
969147cd59 | ||
|
|
6369012389 | ||
|
|
69b7777db4 | ||
|
|
b9324e6320 | ||
|
|
04a1a84077 | ||
|
|
735b70b7fe | ||
|
|
61d9ae397a | ||
|
|
ea5d86e295 | ||
|
|
dd06c7006d | ||
|
|
4d36741e50 | ||
|
|
a9b9dd4b66 | ||
|
|
fbb1f1f266 | ||
|
|
c35fe0d457 | ||
|
|
ec081b6f2e | ||
|
|
4518ea2092 | ||
|
|
d549aa6a62 | ||
|
|
62474c1222 | ||
|
|
26ff4075df | ||
|
|
22f704dd59 | ||
|
|
d22aa0583c | ||
|
|
70bb32c590 | ||
|
|
843313ddb9 | ||
|
|
b202974a7d | ||
|
|
c56ddf3ec1 | ||
|
|
b814bdc612 | ||
|
|
d8ab7a59ff | ||
|
|
f718ab334e | ||
|
|
668aaf9a91 | ||
|
|
ef10996dd8 | ||
|
|
a05b75fc67 | ||
|
|
f96114ad80 | ||
|
|
5ac32f9f24 | ||
|
|
7b398939f7 | ||
|
|
fd8f0e8f1f | ||
|
|
4f2268e66f | ||
|
|
b99d532582 | ||
|
|
fb2bb99a2c | ||
|
|
785172fa7b | ||
|
|
43701915f1 | ||
|
|
2619733915 | ||
|
|
63568a4887 | ||
|
|
8aa496b773 | ||
|
|
1ce153371a | ||
|
|
41849654a7 | ||
|
|
a475361b80 | ||
|
|
1dc5bbd9bd | ||
|
|
d55e934978 | ||
|
|
dddb866233 | ||
|
|
0b58092c8a | ||
|
|
759955e05e | ||
|
|
5949005458 | ||
|
|
71b550f7e6 | ||
|
|
832a98734a | ||
|
|
65b3ce831f | ||
|
|
6613cb7587 | ||
|
|
75a43896a2 | ||
|
|
64e48a7bbe | ||
|
|
5434d9730d | ||
|
|
373c78a927 | ||
|
|
53b66e41e2 | ||
|
|
0f100c7bc8 | ||
|
|
856b6ceec6 | ||
|
|
a14cc09933 | ||
|
|
94c00312c1 | ||
|
|
e4aefe7f9d | ||
|
|
15c81a0982 | ||
|
|
4b44bc86b4 | ||
|
|
6da122eab7 |
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
@@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about.
|
|||||||
|
|
||||||
Before submitting this PR, please make sure that:
|
Before submitting this PR, please make sure that:
|
||||||
|
|
||||||
- [] You created a dedicated branch based on the `canary` branch.
|
- [ ] 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 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.
|
||||||
|
|
||||||
## Issues related (if applicable)
|
## Issues related (if applicable)
|
||||||
|
|
||||||
|
|||||||
26
.github/workflows/pull-request.yml
vendored
26
.github/workflows/pull-request.yml
vendored
@@ -20,6 +20,32 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20.16.0
|
node-version: 20.16.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install Nixpacks
|
||||||
|
if: matrix.job == 'test'
|
||||||
|
run: |
|
||||||
|
export NIXPACKS_VERSION=1.39.0
|
||||||
|
curl -sSL https://nixpacks.com/install.sh | bash
|
||||||
|
echo "Nixpacks installed $NIXPACKS_VERSION"
|
||||||
|
|
||||||
|
- name: Install Railpack
|
||||||
|
if: matrix.job == 'test'
|
||||||
|
run: |
|
||||||
|
export RAILPACK_VERSION=0.15.0
|
||||||
|
curl -sSL https://railpack.com/install.sh | bash
|
||||||
|
echo "Railpack installed $RAILPACK_VERSION"
|
||||||
|
|
||||||
|
- name: Add build tools to PATH
|
||||||
|
if: matrix.job == 'test'
|
||||||
|
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Initialize Docker Swarm
|
||||||
|
if: matrix.job == 'test'
|
||||||
|
run: |
|
||||||
|
docker swarm init
|
||||||
|
docker network create --driver overlay dokploy-network || true
|
||||||
|
echo "✅ Docker Swarm initialized"
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm server:build
|
- run: pnpm server:build
|
||||||
- run: pnpm ${{ matrix.job }}
|
- run: pnpm ${{ matrix.job }}
|
||||||
|
|||||||
70
.github/workflows/sync-openapi-docs.yml
vendored
Normal file
70
.github/workflows/sync-openapi-docs.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
name: Generate and Sync OpenAPI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- canary
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'apps/dokploy/server/api/routers/**'
|
||||||
|
- 'packages/server/src/services/**'
|
||||||
|
- 'packages/server/src/db/schema/**'
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
generate-and-commit:
|
||||||
|
name: Generate OpenAPI and commit to Dokploy repo
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Dokploy repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20.16.0
|
||||||
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Generate OpenAPI specification
|
||||||
|
run: |
|
||||||
|
pnpm generate:openapi
|
||||||
|
|
||||||
|
# Verifica que se generó correctamente
|
||||||
|
if [ ! -f openapi.json ]; then
|
||||||
|
echo "❌ openapi.json not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ OpenAPI specification generated successfully"
|
||||||
|
|
||||||
|
- name: Sync to website repository
|
||||||
|
run: |
|
||||||
|
# Clona el repositorio de website
|
||||||
|
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
|
||||||
|
|
||||||
|
cd website-repo
|
||||||
|
|
||||||
|
# Copia el openapi.json al website (sobrescribe)
|
||||||
|
mkdir -p apps/docs/public
|
||||||
|
cp -f ../openapi.json apps/docs/public/openapi.json
|
||||||
|
|
||||||
|
# Configura git
|
||||||
|
git config user.name "Dokploy Bot"
|
||||||
|
git config user.email "bot@dokploy.com"
|
||||||
|
|
||||||
|
# Agrega y commitea siempre
|
||||||
|
git add apps/docs/public/openapi.json
|
||||||
|
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||||
|
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||||
|
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||||
|
--allow-empty
|
||||||
|
|
||||||
|
git push
|
||||||
|
|
||||||
|
echo "✅ OpenAPI synced to website successfully"
|
||||||
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,6 +13,8 @@ node_modules
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
|
openapi.json
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ COPY --from=build /prod/dokploy/node_modules ./node_modules
|
|||||||
|
|
||||||
|
|
||||||
# Install docker
|
# Install docker
|
||||||
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh && curl https://rclone.org/install.sh | bash
|
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash
|
||||||
|
|
||||||
# Install Nixpacks and tsx
|
# Install Nixpacks and tsx
|
||||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
deployRemoteApplication,
|
deployApplication,
|
||||||
deployRemoteCompose,
|
deployCompose,
|
||||||
deployRemotePreviewApplication,
|
deployPreviewApplication,
|
||||||
rebuildRemoteApplication,
|
rebuildApplication,
|
||||||
rebuildRemoteCompose,
|
rebuildCompose,
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -16,13 +16,13 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
await updateApplicationStatus(job.applicationId, "running");
|
await updateApplicationStatus(job.applicationId, "running");
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "redeploy") {
|
if (job.type === "redeploy") {
|
||||||
await rebuildRemoteApplication({
|
await rebuildApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Rebuild deployment",
|
titleLog: job.titleLog || "Rebuild deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
});
|
});
|
||||||
} else if (job.type === "deploy") {
|
} else if (job.type === "deploy") {
|
||||||
await deployRemoteApplication({
|
await deployApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Manual deployment",
|
titleLog: job.titleLog || "Manual deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
@@ -36,13 +36,13 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
|
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "redeploy") {
|
if (job.type === "redeploy") {
|
||||||
await rebuildRemoteCompose({
|
await rebuildCompose({
|
||||||
composeId: job.composeId,
|
composeId: job.composeId,
|
||||||
titleLog: job.titleLog || "Rebuild deployment",
|
titleLog: job.titleLog || "Rebuild deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
});
|
});
|
||||||
} else if (job.type === "deploy") {
|
} else if (job.type === "deploy") {
|
||||||
await deployRemoteCompose({
|
await deployCompose({
|
||||||
composeId: job.composeId,
|
composeId: job.composeId,
|
||||||
titleLog: job.titleLog || "Manual deployment",
|
titleLog: job.titleLog || "Manual deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
@@ -55,7 +55,7 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
});
|
});
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "deploy") {
|
if (job.type === "deploy") {
|
||||||
await deployRemotePreviewApplication({
|
await deployPreviewApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Preview Deployment",
|
titleLog: job.titleLog || "Preview Deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|||||||
276
apps/dokploy/__test__/deploy/application.command.test.ts
Normal file
276
apps/dokploy/__test__/deploy/application.command.test.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
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 builders from "@dokploy/server/utils/builders";
|
||||||
|
import * as notifications from "@dokploy/server/utils/notifications/build-success";
|
||||||
|
import * as execProcess from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import * as gitProvider from "@dokploy/server/utils/providers/git";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/db", () => {
|
||||||
|
const createChainableMock = (): any => {
|
||||||
|
const chain = {
|
||||||
|
set: vi.fn(() => chain),
|
||||||
|
where: vi.fn(() => chain),
|
||||||
|
returning: vi.fn().mockResolvedValue([{}] as any),
|
||||||
|
} as any;
|
||||||
|
return chain;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
select: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(() => createChainableMock()),
|
||||||
|
delete: vi.fn(),
|
||||||
|
query: {
|
||||||
|
applications: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/application", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("@dokploy/server/services/application")
|
||||||
|
>("@dokploy/server/services/application");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
findApplicationById: vi.fn(),
|
||||||
|
updateApplicationStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/admin", () => ({
|
||||||
|
getDokployUrl: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||||
|
createDeployment: vi.fn(),
|
||||||
|
updateDeploymentStatus: vi.fn(),
|
||||||
|
updateDeployment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/providers/git", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("@dokploy/server/utils/providers/git")
|
||||||
|
>("@dokploy/server/utils/providers/git");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
getGitCommitInfo: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||||
|
execAsync: vi.fn(),
|
||||||
|
ExecError: class ExecError extends Error {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/builders", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("@dokploy/server/utils/builders")
|
||||||
|
>("@dokploy/server/utils/builders");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
mechanizeDockerContainer: vi.fn(),
|
||||||
|
getBuildCommand: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
|
||||||
|
sendBuildSuccessNotifications: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
|
||||||
|
sendBuildErrorNotifications: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||||
|
createRollback: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
|
||||||
|
|
||||||
|
const createMockApplication = (overrides = {}) => ({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
name: "Test App",
|
||||||
|
appName: "test-app",
|
||||||
|
sourceType: "git" as const,
|
||||||
|
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||||
|
customGitBranch: "main",
|
||||||
|
customGitSSHKeyId: null,
|
||||||
|
buildType: "nixpacks" as const,
|
||||||
|
buildPath: "/astro",
|
||||||
|
env: "NODE_ENV=production",
|
||||||
|
serverId: null,
|
||||||
|
rollbackActive: false,
|
||||||
|
enableSubmodules: false,
|
||||||
|
environmentId: "env-id",
|
||||||
|
environment: {
|
||||||
|
projectId: "project-id",
|
||||||
|
env: "",
|
||||||
|
name: "production",
|
||||||
|
project: {
|
||||||
|
name: "Test Project",
|
||||||
|
organizationId: "org-id",
|
||||||
|
env: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
domains: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMockDeployment = () => ({
|
||||||
|
deploymentId: "deployment-id",
|
||||||
|
logPath: "/tmp/test-deployment.log",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deployApplication - Command Generation Tests", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
createMockApplication() as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
createMockApplication() as any,
|
||||||
|
);
|
||||||
|
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
|
||||||
|
"http://localhost:3000",
|
||||||
|
);
|
||||||
|
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
|
||||||
|
createMockDeployment() as any,
|
||||||
|
);
|
||||||
|
vi.mocked(execProcess.execAsync).mockResolvedValue({
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
} as any);
|
||||||
|
vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
|
||||||
|
undefined as any,
|
||||||
|
);
|
||||||
|
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
|
||||||
|
undefined as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
|
||||||
|
{} as any,
|
||||||
|
);
|
||||||
|
vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
|
||||||
|
undefined as any,
|
||||||
|
);
|
||||||
|
vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({
|
||||||
|
message: "test commit",
|
||||||
|
hash: "abc123",
|
||||||
|
});
|
||||||
|
vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate correct git clone command for astro example", async () => {
|
||||||
|
const app = createMockApplication();
|
||||||
|
const command = await cloneGitRepository(app);
|
||||||
|
console.log(command);
|
||||||
|
|
||||||
|
expect(command).toContain("https://github.com/Dokploy/examples.git");
|
||||||
|
expect(command).not.toContain("--recurse-submodules");
|
||||||
|
expect(command).toContain("--branch main");
|
||||||
|
expect(command).toContain("--depth 1");
|
||||||
|
expect(command).toContain("git clone");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate git clone with submodules when enabled", async () => {
|
||||||
|
const app = createMockApplication({ enableSubmodules: true });
|
||||||
|
const command = await cloneGitRepository(app);
|
||||||
|
|
||||||
|
expect(command).toContain("--recurse-submodules");
|
||||||
|
expect(command).toContain("https://github.com/Dokploy/examples.git");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should verify nixpacks command is called with correct app", async () => {
|
||||||
|
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
|
||||||
|
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
|
||||||
|
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Test deployment",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(builders.getBuildCommand).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
buildType: "nixpacks",
|
||||||
|
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||||
|
buildPath: "/astro",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(execProcess.execAsync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("nixpacks build"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should verify railpack command includes correct parameters", async () => {
|
||||||
|
const mockApp = createMockApplication({ buildType: "railpack" });
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
mockApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
mockApp as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockRailpackCommand = "railpack prepare /path/to/app";
|
||||||
|
vi.mocked(builders.getBuildCommand).mockReturnValue(mockRailpackCommand);
|
||||||
|
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Railpack test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(builders.getBuildCommand).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
buildType: "railpack",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(execProcess.execAsync).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("railpack prepare"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should execute commands in correct order", async () => {
|
||||||
|
const mockNixpacksCommand = "nixpacks build";
|
||||||
|
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
|
||||||
|
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
|
||||||
|
expect(execCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const fullCommand = execCalls[0]?.[0];
|
||||||
|
expect(fullCommand).toContain("set -e");
|
||||||
|
expect(fullCommand).toContain("git clone");
|
||||||
|
expect(fullCommand).toContain("nixpacks build");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include log redirection in command", async () => {
|
||||||
|
const mockCommand = "nixpacks build";
|
||||||
|
vi.mocked(builders.getBuildCommand).mockReturnValue(mockCommand);
|
||||||
|
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
|
||||||
|
const fullCommand = execCalls[0]?.[0];
|
||||||
|
|
||||||
|
expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1");
|
||||||
|
});
|
||||||
|
});
|
||||||
479
apps/dokploy/__test__/deploy/application.real.test.ts
Normal file
479
apps/dokploy/__test__/deploy/application.real.test.ts
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { ApplicationNested } from "@dokploy/server";
|
||||||
|
import { paths } from "@dokploy/server/constants";
|
||||||
|
import { execAsync } from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
|
||||||
|
|
||||||
|
// Mock ONLY database and notifications
|
||||||
|
vi.mock("@dokploy/server/db", () => {
|
||||||
|
const createChainableMock = (): any => {
|
||||||
|
const chain: any = {
|
||||||
|
set: vi.fn(() => chain),
|
||||||
|
where: vi.fn(() => chain),
|
||||||
|
returning: vi.fn().mockResolvedValue([{}]),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
select: vi.fn(),
|
||||||
|
insert: vi.fn(),
|
||||||
|
update: vi.fn(() => createChainableMock()),
|
||||||
|
delete: vi.fn(),
|
||||||
|
query: {
|
||||||
|
applications: {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/application", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("@dokploy/server/services/application")
|
||||||
|
>("@dokploy/server/services/application");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
findApplicationById: vi.fn(),
|
||||||
|
updateApplicationStatus: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/admin", () => ({
|
||||||
|
getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||||
|
createDeployment: vi.fn(),
|
||||||
|
updateDeploymentStatus: vi.fn(),
|
||||||
|
updateDeployment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
|
||||||
|
sendBuildSuccessNotifications: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
|
||||||
|
sendBuildErrorNotifications: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||||
|
createRollback: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// NOT mocked (executed for real):
|
||||||
|
// - execAsync
|
||||||
|
// - cloneGitRepository
|
||||||
|
// - getBuildCommand
|
||||||
|
// - mechanizeDockerContainer (requires Docker Swarm)
|
||||||
|
|
||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const createMockApplication = (
|
||||||
|
overrides: Partial<ApplicationNested> = {},
|
||||||
|
): ApplicationNested =>
|
||||||
|
({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
name: "Real Test App",
|
||||||
|
appName: `real-test-${Date.now()}`,
|
||||||
|
sourceType: "git" as const,
|
||||||
|
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||||
|
customGitBranch: "main",
|
||||||
|
customGitSSHKeyId: null,
|
||||||
|
customGitBuildPath: "/astro",
|
||||||
|
buildType: "nixpacks" as const,
|
||||||
|
env: "NODE_ENV=production",
|
||||||
|
serverId: null,
|
||||||
|
rollbackActive: false,
|
||||||
|
enableSubmodules: false,
|
||||||
|
environmentId: "env-id",
|
||||||
|
environment: {
|
||||||
|
projectId: "project-id",
|
||||||
|
env: "",
|
||||||
|
name: "production",
|
||||||
|
project: {
|
||||||
|
name: "Test Project",
|
||||||
|
organizationId: "org-id",
|
||||||
|
env: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
domains: [],
|
||||||
|
mounts: [],
|
||||||
|
security: [],
|
||||||
|
redirects: [],
|
||||||
|
ports: [],
|
||||||
|
registry: null,
|
||||||
|
...overrides,
|
||||||
|
}) as ApplicationNested;
|
||||||
|
|
||||||
|
const createMockDeployment = async (appName: string) => {
|
||||||
|
const { LOGS_PATH } = paths(false); // false = local, no remote server
|
||||||
|
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||||
|
const fileName = `${appName}-${formattedDateTime}.log`;
|
||||||
|
const logFilePath = path.join(LOGS_PATH, appName, fileName);
|
||||||
|
|
||||||
|
// Actually create the log directory
|
||||||
|
await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
|
||||||
|
await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
deploymentId: "deployment-id",
|
||||||
|
logPath: logFilePath,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function cleanupDocker(appName: string) {
|
||||||
|
try {
|
||||||
|
await execAsync(`docker stop ${appName} 2>/dev/null || true`);
|
||||||
|
await execAsync(`docker rm ${appName} 2>/dev/null || true`);
|
||||||
|
await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Docker cleanup completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupFiles(appName: string) {
|
||||||
|
try {
|
||||||
|
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||||
|
|
||||||
|
// Clean cloned code directories
|
||||||
|
const appPath = path.join(APPLICATIONS_PATH, appName);
|
||||||
|
await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
|
||||||
|
|
||||||
|
// Clean logs for appName - removes entire folder
|
||||||
|
const logPath = path.join(LOGS_PATH, appName);
|
||||||
|
await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
|
||||||
|
|
||||||
|
console.log(`✅ Cleaned up files and logs for ${appName}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(
|
||||||
|
"deployApplication - REAL Execution Tests",
|
||||||
|
() => {
|
||||||
|
let currentAppName: string;
|
||||||
|
let currentDeployment: any;
|
||||||
|
const allTestAppNames: string[] = [];
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
currentAppName = `real-test-${Date.now()}`;
|
||||||
|
currentDeployment = await createMockDeployment(currentAppName);
|
||||||
|
allTestAppNames.push(currentAppName);
|
||||||
|
|
||||||
|
const mockApp = createMockApplication({ appName: currentAppName });
|
||||||
|
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
mockApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
mockApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
|
||||||
|
"http://localhost:3000",
|
||||||
|
);
|
||||||
|
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
|
||||||
|
currentDeployment as any,
|
||||||
|
);
|
||||||
|
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
|
||||||
|
undefined as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
|
||||||
|
{} as any,
|
||||||
|
);
|
||||||
|
vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
|
||||||
|
{} as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// ALWAYS cleanup, even if test failed or passed
|
||||||
|
console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
|
||||||
|
|
||||||
|
// Clean current appName
|
||||||
|
try {
|
||||||
|
await cleanupDocker(currentAppName);
|
||||||
|
await cleanupFiles(currentAppName);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("⚠️ Error cleaning current app:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean ALL test folders just in case
|
||||||
|
try {
|
||||||
|
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||||
|
await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
|
||||||
|
await execAsync(
|
||||||
|
`rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
|
||||||
|
);
|
||||||
|
console.log("✅ Cleaned up all test artifacts");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("⚠️ Error cleaning all artifacts:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("✅ Cleanup completed\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(
|
||||||
|
"should REALLY clone git repo and build with nixpacks",
|
||||||
|
async () => {
|
||||||
|
console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
|
||||||
|
|
||||||
|
const result = await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Nixpacks Test",
|
||||||
|
descriptionLog: "Testing real execution",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Verify that Docker image was actually created
|
||||||
|
const { stdout: dockerImages } = await execAsync(
|
||||||
|
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||||
|
);
|
||||||
|
console.log("dockerImages", dockerImages);
|
||||||
|
expect(dockerImages.trim()).toBe(currentAppName);
|
||||||
|
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||||
|
|
||||||
|
// Verify log exists and has content
|
||||||
|
expect(existsSync(currentDeployment.logPath)).toBe(true);
|
||||||
|
const { stdout: logContent } = await execAsync(
|
||||||
|
`cat ${currentDeployment.logPath}`,
|
||||||
|
);
|
||||||
|
expect(logContent).toContain("Cloning");
|
||||||
|
expect(logContent).toContain("nixpacks");
|
||||||
|
console.log(`✅ Build log created with ${logContent.length} chars`);
|
||||||
|
|
||||||
|
// Verify update functions were called
|
||||||
|
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||||
|
"deployment-id",
|
||||||
|
"done",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it.skip(
|
||||||
|
"should REALLY build with railpack (SKIPPED: requires special permissions)",
|
||||||
|
async () => {
|
||||||
|
const railpackAppName = `real-railpack-${Date.now()}`;
|
||||||
|
const railpackApp = createMockApplication({
|
||||||
|
appName: railpackAppName,
|
||||||
|
buildType: "railpack",
|
||||||
|
railpackVersion: "3",
|
||||||
|
});
|
||||||
|
currentAppName = railpackAppName;
|
||||||
|
allTestAppNames.push(railpackAppName);
|
||||||
|
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
railpackApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
railpackApp as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
|
||||||
|
|
||||||
|
const result = await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Railpack Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const { stdout: dockerImages } = await execAsync(
|
||||||
|
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||||
|
);
|
||||||
|
expect(dockerImages.trim()).toBe(currentAppName);
|
||||||
|
console.log(`✅ Railpack image created: ${currentAppName}`);
|
||||||
|
|
||||||
|
const { stdout: logContent } = await execAsync(
|
||||||
|
`cat ${currentDeployment.logPath}`,
|
||||||
|
);
|
||||||
|
expect(logContent).toContain("railpack");
|
||||||
|
console.log("✅ Railpack build completed");
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"should handle REAL git clone errors",
|
||||||
|
async () => {
|
||||||
|
const errorAppName = `real-error-${Date.now()}`;
|
||||||
|
const errorApp = createMockApplication({
|
||||||
|
appName: errorAppName,
|
||||||
|
customGitUrl:
|
||||||
|
"https://github.com/invalid/nonexistent-repo-123456.git",
|
||||||
|
});
|
||||||
|
currentAppName = errorAppName;
|
||||||
|
allTestAppNames.push(errorAppName);
|
||||||
|
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
errorApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
errorApp as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Error Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
// Verify error status was called
|
||||||
|
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||||
|
"deployment-id",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify log contains error
|
||||||
|
const { stdout: logContent } = await execAsync(
|
||||||
|
`cat ${currentDeployment.logPath}`,
|
||||||
|
);
|
||||||
|
expect(logContent.toLowerCase()).toContain("error");
|
||||||
|
console.log("✅ Error handling verified");
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"should REALLY clone with submodules when enabled",
|
||||||
|
async () => {
|
||||||
|
const submodulesAppName = `real-submodules-${Date.now()}`;
|
||||||
|
const submodulesApp = createMockApplication({
|
||||||
|
appName: submodulesAppName,
|
||||||
|
enableSubmodules: true,
|
||||||
|
});
|
||||||
|
currentAppName = submodulesAppName;
|
||||||
|
allTestAppNames.push(submodulesAppName);
|
||||||
|
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
submodulesApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
submodulesApp as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
|
||||||
|
|
||||||
|
const result = await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Submodules Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Verify deployment completed successfully
|
||||||
|
const { stdout: logContent } = await execAsync(
|
||||||
|
`cat ${currentDeployment.logPath}`,
|
||||||
|
);
|
||||||
|
expect(logContent).toContain("Cloning");
|
||||||
|
expect(logContent.length).toBeGreaterThan(100);
|
||||||
|
console.log("✅ Submodules deployment completed");
|
||||||
|
|
||||||
|
// Verify image
|
||||||
|
const { stdout: dockerImages } = await execAsync(
|
||||||
|
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||||
|
);
|
||||||
|
expect(dockerImages.trim()).toBe(currentAppName);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"should verify REAL commit info extraction",
|
||||||
|
async () => {
|
||||||
|
console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
|
||||||
|
|
||||||
|
await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Commit Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify updateDeployment was called with commit info
|
||||||
|
expect(deploymentService.updateDeployment).toHaveBeenCalled();
|
||||||
|
const updateCall = vi.mocked(deploymentService.updateDeployment).mock
|
||||||
|
.calls[0];
|
||||||
|
|
||||||
|
// Real commit info should have title and hash
|
||||||
|
expect(updateCall?.[1]).toHaveProperty("title");
|
||||||
|
expect(updateCall?.[1]).toHaveProperty("description");
|
||||||
|
expect(updateCall?.[1]?.description).toContain("Commit:");
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
"should REALLY build with Dockerfile",
|
||||||
|
async () => {
|
||||||
|
const dockerfileAppName = `real-dockerfile-${Date.now()}`;
|
||||||
|
const dockerfileApp = createMockApplication({
|
||||||
|
appName: dockerfileAppName,
|
||||||
|
buildType: "dockerfile",
|
||||||
|
customGitBuildPath: "/deno",
|
||||||
|
dockerfile: "Dockerfile",
|
||||||
|
});
|
||||||
|
currentAppName = dockerfileAppName;
|
||||||
|
allTestAppNames.push(dockerfileAppName);
|
||||||
|
|
||||||
|
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||||
|
dockerfileApp as any,
|
||||||
|
);
|
||||||
|
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||||
|
dockerfileApp as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
|
||||||
|
|
||||||
|
const result = await deployApplication({
|
||||||
|
applicationId: "test-app-id",
|
||||||
|
titleLog: "Real Dockerfile Test",
|
||||||
|
descriptionLog: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Verify log
|
||||||
|
const { stdout: logContent } = await execAsync(
|
||||||
|
`cat ${currentDeployment.logPath}`,
|
||||||
|
);
|
||||||
|
expect(logContent).toContain("Building");
|
||||||
|
expect(logContent).toContain(dockerfileAppName);
|
||||||
|
console.log("✅ Dockerfile build log verified");
|
||||||
|
|
||||||
|
// Verify image
|
||||||
|
const { stdout: dockerImages } = await execAsync(
|
||||||
|
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||||
|
);
|
||||||
|
console.log("dockerImages", dockerImages);
|
||||||
|
expect(dockerImages.trim()).toBe(currentAppName);
|
||||||
|
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
REAL_TEST_TIMEOUT,
|
||||||
|
);
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
|
import {
|
||||||
|
extractCommitMessage,
|
||||||
|
extractImageName,
|
||||||
|
extractImageTag,
|
||||||
|
extractImageTagFromRequest,
|
||||||
|
} from "@/pages/api/deploy/[refreshToken]";
|
||||||
|
|
||||||
describe("GitHub Webhook Skip CI", () => {
|
describe("GitHub Webhook Skip CI", () => {
|
||||||
const mockGithubHeaders = {
|
const mockGithubHeaders = {
|
||||||
@@ -96,3 +101,308 @@ describe("GitHub Webhook Skip CI", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GitHub Packages Docker Image Tag Extraction", () => {
|
||||||
|
it("should extract tag from container_metadata", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
container_metadata: {
|
||||||
|
tag: {
|
||||||
|
name: "v1.0.0",
|
||||||
|
digest: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
package_url: "ghcr.io/owner/repo:v1.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBe("v1.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract tag from package_url when container_metadata tag matches version", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
container_metadata: {
|
||||||
|
tag: {
|
||||||
|
name: "sha256:abc123...",
|
||||||
|
digest: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
package_url: "ghcr.io/owner/repo:latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBe("latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract tag from package_url when container_metadata is missing", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
package_url: "ghcr.io/owner/repo:1.2.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBe("1.2.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle different tag formats in package_url", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const testCases = [
|
||||||
|
{ url: "ghcr.io/owner/repo:latest", expected: "latest" },
|
||||||
|
{ url: "ghcr.io/owner/repo:v1.0.0", expected: "v1.0.0" },
|
||||||
|
{ url: "ghcr.io/owner/repo:1.2.3", expected: "1.2.3" },
|
||||||
|
{ url: "ghcr.io/owner/repo:dev", expected: "dev" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
package_url: testCase.url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBe(testCase.expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for non-registry_package events", () => {
|
||||||
|
const headers = { "x-github-event": "push" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
package_url: "ghcr.io/owner/repo:latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when package_version is missing", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when package_url has no tag", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
package_url: "ghcr.io/owner/repo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when package_url ends with colon (no tag)", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
package_url: "ghcr.io/owner/repo:",
|
||||||
|
container_metadata: {
|
||||||
|
tag: {
|
||||||
|
name: "",
|
||||||
|
digest: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when tag name is empty string", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
container_metadata: {
|
||||||
|
tag: {
|
||||||
|
name: "",
|
||||||
|
digest: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
package_url: "ghcr.io/owner/repo:",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore tag if it matches the version (digest)", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
container_metadata: {
|
||||||
|
tag: {
|
||||||
|
name: "sha256:abc123...",
|
||||||
|
digest: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
package_url: "ghcr.io/owner/repo:latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const tag = extractImageTagFromRequest(headers, body);
|
||||||
|
expect(tag).toBe("latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle registry_package commit message with package_url", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
package_url: "ghcr.io/owner/repo:latest",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = extractCommitMessage(headers, body);
|
||||||
|
expect(message).toBe("Docker GHCR image pushed: ghcr.io/owner/repo:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle registry_package commit message when package_url is missing", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {
|
||||||
|
package_version: {
|
||||||
|
version: "sha256:abc123...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = extractCommitMessage(headers, body);
|
||||||
|
expect(message).toBe("Docker GHCR image pushed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle registry_package commit message when package_version is missing", () => {
|
||||||
|
const headers = { "x-github-event": "registry_package" };
|
||||||
|
const body = {
|
||||||
|
registry_package: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const message = extractCommitMessage(headers, body);
|
||||||
|
expect(message).toBe("NEW COMMIT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Docker Image Name and Tag Extraction", () => {
|
||||||
|
describe("extractImageName", () => {
|
||||||
|
it("should return image name without tag", () => {
|
||||||
|
expect(extractImageName("my-image:latest")).toBe("my-image");
|
||||||
|
expect(extractImageName("my-image:1.0.0")).toBe("my-image");
|
||||||
|
expect(extractImageName("ghcr.io/owner/repo:latest")).toBe(
|
||||||
|
"ghcr.io/owner/repo",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return full image name when no tag is present", () => {
|
||||||
|
expect(extractImageName("my-image")).toBe("my-image");
|
||||||
|
expect(extractImageName("ghcr.io/owner/repo")).toBe("ghcr.io/owner/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle images with port numbers correctly", () => {
|
||||||
|
expect(extractImageName("registry:5000/image:tag")).toBe(
|
||||||
|
"registry:5000/image",
|
||||||
|
);
|
||||||
|
expect(extractImageName("localhost:5000/my-app:latest")).toBe(
|
||||||
|
"localhost:5000/my-app",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex image paths", () => {
|
||||||
|
expect(
|
||||||
|
extractImageName("myregistryhost:5000/fedora/httpd:version1.0"),
|
||||||
|
).toBe("myregistryhost:5000/fedora/httpd");
|
||||||
|
expect(extractImageName("registry.example.com:8080/ns/app:v1.2.3")).toBe(
|
||||||
|
"registry.example.com:8080/ns/app",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid inputs", () => {
|
||||||
|
expect(extractImageName(null)).toBeNull();
|
||||||
|
expect(extractImageName("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle edge cases with multiple colons", () => {
|
||||||
|
expect(extractImageName("image:tag:extra")).toBe("image:tag");
|
||||||
|
expect(extractImageName("registry:5000:invalid")).toBe("registry:5000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractImageTag", () => {
|
||||||
|
it("should extract tag from image with tag", () => {
|
||||||
|
expect(extractImageTag("my-image:latest")).toBe("latest");
|
||||||
|
expect(extractImageTag("my-image:1.0.0")).toBe("1.0.0");
|
||||||
|
expect(extractImageTag("ghcr.io/owner/repo:v1.2.3")).toBe("v1.2.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 'latest' when no tag is present", () => {
|
||||||
|
expect(extractImageTag("my-image")).toBe("latest");
|
||||||
|
expect(extractImageTag("ghcr.io/owner/repo")).toBe("latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex image paths with tags", () => {
|
||||||
|
expect(
|
||||||
|
extractImageTag("myregistryhost:5000/fedora/httpd:version1.0"),
|
||||||
|
).toBe("version1.0");
|
||||||
|
expect(extractImageTag("registry.example.com:8080/ns/app:v1.2.3")).toBe(
|
||||||
|
"v1.2.3",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for invalid inputs", () => {
|
||||||
|
expect(extractImageTag(null)).toBeNull();
|
||||||
|
expect(extractImageTag("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle edge cases with multiple colons", () => {
|
||||||
|
expect(extractImageTag("image:tag:extra")).toBe("extra");
|
||||||
|
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle numeric tags", () => {
|
||||||
|
expect(extractImageTag("my-image:123")).toBe("123");
|
||||||
|
expect(extractImageTag("my-image:1")).toBe("1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ const baseApp: ApplicationNested = {
|
|||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaBranch: "",
|
giteaBranch: "",
|
||||||
|
buildServerId: "",
|
||||||
|
buildRegistryId: "",
|
||||||
|
buildRegistry: null,
|
||||||
|
args: [],
|
||||||
giteaBuildPath: "",
|
giteaBuildPath: "",
|
||||||
previewRequireCollaboratorPermissions: false,
|
previewRequireCollaboratorPermissions: false,
|
||||||
giteaId: "",
|
giteaId: "",
|
||||||
@@ -42,6 +46,7 @@ const baseApp: ApplicationNested = {
|
|||||||
triggerType: "push",
|
triggerType: "push",
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
|
endpointSpecSwarm: null,
|
||||||
serverId: "",
|
serverId: "",
|
||||||
registryUrl: "",
|
registryUrl: "",
|
||||||
branch: null,
|
branch: null,
|
||||||
|
|||||||
311
apps/dokploy/__test__/env/environment.test.ts
vendored
311
apps/dokploy/__test__/env/environment.test.ts
vendored
@@ -1,4 +1,7 @@
|
|||||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
import {
|
||||||
|
prepareEnvironmentVariables,
|
||||||
|
prepareEnvironmentVariablesForShell,
|
||||||
|
} from "@dokploy/server/index";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const projectEnv = `
|
const projectEnv = `
|
||||||
@@ -332,4 +335,310 @@ IS_DEV=\${{environment.DEVELOPMENT}}
|
|||||||
"IS_DEV=0",
|
"IS_DEV=0",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with single quotes in values", () => {
|
||||||
|
const envWithSingleQuotes = `
|
||||||
|
ENV_VARIABLE='ENVITONME'NT'
|
||||||
|
ANOTHER_VAR='value with 'quotes' inside'
|
||||||
|
SIMPLE_VAR=no-quotes
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serviceWithSingleQuotes = `
|
||||||
|
TEST_VAR=\${{environment.ENV_VARIABLE}}
|
||||||
|
ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
|
||||||
|
SIMPLE=\${{environment.SIMPLE_VAR}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariables(
|
||||||
|
serviceWithSingleQuotes,
|
||||||
|
"",
|
||||||
|
envWithSingleQuotes,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
"TEST_VAR=ENVITONME'NT",
|
||||||
|
"ANOTHER_TEST=value with 'quotes' inside",
|
||||||
|
"SIMPLE=no-quotes",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
|
||||||
|
it("escapes single quotes in environment variable values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
ENV_VARIABLE='ENVITONME'NT'
|
||||||
|
ANOTHER_VAR='value with 'quotes' inside'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// shell-quote should wrap these in double quotes
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
`"ENV_VARIABLE=ENVITONME'NT"`,
|
||||||
|
`"ANOTHER_VAR=value with 'quotes' inside"`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes double quotes in environment variable values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
MESSAGE="Hello "World""
|
||||||
|
QUOTED_PATH="/path/to/"file""
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// shell-quote wraps in single quotes when there are double quotes inside
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
`'MESSAGE=Hello "World"'`,
|
||||||
|
`'QUOTED_PATH=/path/to/"file"'`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes dollar signs in environment variable values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
PRICE=$100
|
||||||
|
VARIABLE=$HOME/path
|
||||||
|
TEMPLATE=Hello $USER
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// Dollar signs should be escaped to prevent variable expansion
|
||||||
|
for (const env of resolved) {
|
||||||
|
expect(env).toContain("$");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes backticks in environment variable values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
COMMAND=\`echo "test"\`
|
||||||
|
NESTED=value with \`backticks\` inside
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
|
||||||
|
expect(resolved.length).toBe(2);
|
||||||
|
expect(resolved[0]).toContain("COMMAND");
|
||||||
|
expect(resolved[1]).toContain("NESTED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with spaces", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
FULL_NAME="John Doe"
|
||||||
|
MESSAGE='Hello World'
|
||||||
|
SENTENCE=This is a test
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// shell-quote uses single quotes for strings with spaces
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
`'FULL_NAME=John Doe'`,
|
||||||
|
`'MESSAGE=Hello World'`,
|
||||||
|
`'SENTENCE=This is a test'`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with backslashes", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
WINDOWS_PATH=C:\\Users\\Documents
|
||||||
|
ESCAPED=value\\with\\backslashes
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// Backslashes should be properly escaped
|
||||||
|
expect(resolved.length).toBe(2);
|
||||||
|
for (const env of resolved) {
|
||||||
|
expect(env).toContain("\\");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles simple environment variables without special characters", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
DEBUG=true
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// shell-quote escapes the = sign in some cases
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
"NODE_ENV\\=production",
|
||||||
|
"PORT\\=3000",
|
||||||
|
"DEBUG\\=true",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with mixed special characters", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
COMPLEX='value with "double" and 'single' quotes'
|
||||||
|
BASH_COMMAND=echo "$HOME" && echo 'test'
|
||||||
|
WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// All should be escaped, none should throw errors
|
||||||
|
expect(resolved.length).toBe(3);
|
||||||
|
// Verify each can be safely used in shell
|
||||||
|
for (const env of resolved) {
|
||||||
|
expect(typeof env).toBe("string");
|
||||||
|
expect(env.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with newlines", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
MULTILINE="line1
|
||||||
|
line2
|
||||||
|
line3"
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(1);
|
||||||
|
expect(resolved[0]).toContain("MULTILINE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty environment variable values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
EMPTY=
|
||||||
|
EMPTY_QUOTED=""
|
||||||
|
EMPTY_SINGLE=''
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
// shell-quote escapes the = sign for empty values
|
||||||
|
expect(resolved).toEqual([
|
||||||
|
"EMPTY\\=",
|
||||||
|
"EMPTY_QUOTED\\=",
|
||||||
|
"EMPTY_SINGLE\\=",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with equals signs in values", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
EQUATION=a=b+c
|
||||||
|
CONNECTION_STRING=user=admin;password=test
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(2);
|
||||||
|
expect(resolved[0]).toContain("EQUATION");
|
||||||
|
expect(resolved[1]).toContain("CONNECTION_STRING");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves and escapes environment variables together", () => {
|
||||||
|
const projectEnv = `
|
||||||
|
BASE_URL=https://example.com
|
||||||
|
API_KEY='secret-key-with-quotes'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const environmentEnv = `
|
||||||
|
ENV_NAME=production
|
||||||
|
DB_PASS='pa$$word'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serviceEnv = `
|
||||||
|
FULL_URL=\${{project.BASE_URL}}/api
|
||||||
|
AUTH_KEY=\${{project.API_KEY}}
|
||||||
|
ENVIRONMENT=\${{environment.ENV_NAME}}
|
||||||
|
DB_PASSWORD=\${{environment.DB_PASS}}
|
||||||
|
CUSTOM='value with 'quotes' inside'
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(
|
||||||
|
serviceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(5);
|
||||||
|
// All resolved values should be properly escaped
|
||||||
|
for (const env of resolved) {
|
||||||
|
expect(typeof env).toBe("string");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with semicolons and ampersands", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
COMMAND=echo "test" && echo "test2"
|
||||||
|
MULTIPLE=cmd1; cmd2; cmd3
|
||||||
|
URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(3);
|
||||||
|
// These should be safely escaped to prevent command injection
|
||||||
|
for (const env of resolved) {
|
||||||
|
expect(typeof env).toBe("string");
|
||||||
|
expect(env.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with pipes and redirects", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
PIPE_COMMAND=cat file | grep test
|
||||||
|
REDIRECT=echo "test" > output.txt
|
||||||
|
BOTH=cat input.txt | grep pattern > output.txt
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(3);
|
||||||
|
// Pipes and redirects should be safely quoted
|
||||||
|
expect(resolved[0]).toContain("PIPE_COMMAND");
|
||||||
|
expect(resolved[1]).toContain("REDIRECT");
|
||||||
|
expect(resolved[2]).toContain("BOTH");
|
||||||
|
// At least one should contain a pipe
|
||||||
|
const hasPipe = resolved.some((env) => env.includes("|"));
|
||||||
|
expect(hasPipe).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles environment variables with parentheses and brackets", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
MATH=(a+b)*c
|
||||||
|
ARRAY=[1,2,3]
|
||||||
|
JSON={"key":"value"}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(3);
|
||||||
|
expect(resolved[0]).toContain("(");
|
||||||
|
expect(resolved[1]).toContain("[");
|
||||||
|
expect(resolved[2]).toContain("{");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles very long environment variable values", () => {
|
||||||
|
const longValue = "a".repeat(10000);
|
||||||
|
const serviceEnv = `LONG_VAR=${longValue}`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(1);
|
||||||
|
expect(resolved[0]).toContain("LONG_VAR");
|
||||||
|
expect(resolved[0]?.length).toBeGreaterThan(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles special unicode characters in environment variables", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
EMOJI=Hello 🌍 World 🚀
|
||||||
|
CHINESE=你好世界
|
||||||
|
SPECIAL=café résumé naïve
|
||||||
|
`;
|
||||||
|
|
||||||
|
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||||
|
|
||||||
|
expect(resolved.length).toBe(3);
|
||||||
|
expect(resolved[0]).toContain("🌍");
|
||||||
|
expect(resolved[1]).toContain("你好");
|
||||||
|
expect(resolved[2]).toContain("café");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
|||||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||||
|
|
||||||
type MockCreateServiceOptions = {
|
type MockCreateServiceOptions = {
|
||||||
StopGracePeriod?: number;
|
TaskTemplate?: {
|
||||||
|
ContainerSpec?: {
|
||||||
|
StopGracePeriod?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,8 +86,10 @@ describe("mechanizeDockerContainer", () => {
|
|||||||
throw new Error("createServiceMock should have been called once");
|
throw new Error("createServiceMock should have been called once");
|
||||||
}
|
}
|
||||||
const [settings] = call;
|
const [settings] = call;
|
||||||
expect(settings.StopGracePeriod).toBe(0);
|
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
|
||||||
expect(typeof settings.StopGracePeriod).toBe("number");
|
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
|
||||||
|
"number",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
||||||
@@ -97,6 +103,8 @@ describe("mechanizeDockerContainer", () => {
|
|||||||
throw new Error("createServiceMock should have been called once");
|
throw new Error("createServiceMock should have been called once");
|
||||||
}
|
}
|
||||||
const [settings] = call;
|
const [settings] = call;
|
||||||
expect(settings).not.toHaveProperty("StopGracePeriod");
|
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
|
||||||
|
"StopGracePeriod",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ const baseApp: ApplicationNested = {
|
|||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
giteaOwner: "",
|
giteaOwner: "",
|
||||||
giteaBranch: "",
|
giteaBranch: "",
|
||||||
|
buildServerId: "",
|
||||||
|
buildRegistryId: "",
|
||||||
|
buildRegistry: null,
|
||||||
giteaBuildPath: "",
|
giteaBuildPath: "",
|
||||||
giteaId: "",
|
giteaId: "",
|
||||||
|
args: [],
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
|
endpointSpecSwarm: null,
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ export default defineConfig({
|
|||||||
NODE: "test",
|
NODE: "test",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [tsconfigPaths()],
|
plugins: [
|
||||||
|
tsconfigPaths({
|
||||||
|
projects: [path.resolve(__dirname, "../tsconfig.json")],
|
||||||
|
}),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@dokploy/server": path.resolve(
|
"@dokploy/server": path.resolve(
|
||||||
|
|||||||
@@ -122,6 +122,22 @@ const NetworkSwarmSchema = z.array(
|
|||||||
|
|
||||||
const LabelsSwarmSchema = z.record(z.string());
|
const LabelsSwarmSchema = z.record(z.string());
|
||||||
|
|
||||||
|
const EndpointPortConfigSwarmSchema = z
|
||||||
|
.object({
|
||||||
|
Protocol: z.string().optional(),
|
||||||
|
TargetPort: z.number().optional(),
|
||||||
|
PublishedPort: z.number().optional(),
|
||||||
|
PublishMode: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const EndpointSpecSwarmSchema = z
|
||||||
|
.object({
|
||||||
|
Mode: z.string().optional(),
|
||||||
|
Ports: z.array(EndpointPortConfigSwarmSchema).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
|
const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
|
||||||
return z
|
return z
|
||||||
.string()
|
.string()
|
||||||
@@ -178,6 +194,9 @@ const addSwarmSettings = z.object({
|
|||||||
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
|
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
|
||||||
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
|
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
|
||||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||||
|
endpointSpecSwarm: createStringToJSONSchema(
|
||||||
|
EndpointSpecSwarmSchema,
|
||||||
|
).nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
|
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
|
||||||
@@ -234,6 +253,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
labelsSwarm: null,
|
labelsSwarm: null,
|
||||||
networkSwarm: null,
|
networkSwarm: null,
|
||||||
stopGracePeriodSwarm: null,
|
stopGracePeriodSwarm: null,
|
||||||
|
endpointSpecSwarm: null,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addSwarmSettings),
|
resolver: zodResolver(addSwarmSettings),
|
||||||
});
|
});
|
||||||
@@ -275,6 +295,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
? JSON.stringify(data.networkSwarm, null, 2)
|
? JSON.stringify(data.networkSwarm, null, 2)
|
||||||
: null,
|
: null,
|
||||||
stopGracePeriodSwarm: normalizedStopGracePeriod,
|
stopGracePeriodSwarm: normalizedStopGracePeriod,
|
||||||
|
endpointSpecSwarm: data.endpointSpecSwarm
|
||||||
|
? JSON.stringify(data.endpointSpecSwarm, null, 2)
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
@@ -296,6 +319,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
labelsSwarm: data.labelsSwarm,
|
labelsSwarm: data.labelsSwarm,
|
||||||
networkSwarm: data.networkSwarm,
|
networkSwarm: data.networkSwarm,
|
||||||
stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
|
stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
|
||||||
|
endpointSpecSwarm: data.endpointSpecSwarm,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Swarm settings updated");
|
toast.success("Swarm settings updated");
|
||||||
@@ -846,6 +870,67 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="endpointSpecSwarm"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="relative ">
|
||||||
|
<FormLabel>Endpoint Spec</FormLabel>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||||
|
Check the interface
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground" />
|
||||||
|
</FormDescription>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="w-full z-[999]"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<code>
|
||||||
|
<pre>
|
||||||
|
{`{
|
||||||
|
Mode?: string | undefined;
|
||||||
|
Ports?: Array<{
|
||||||
|
Protocol?: string | undefined;
|
||||||
|
TargetPort?: number | undefined;
|
||||||
|
PublishedPort?: number | undefined;
|
||||||
|
PublishMode?: string | undefined;
|
||||||
|
}> | undefined;
|
||||||
|
}`}
|
||||||
|
</pre>
|
||||||
|
</code>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
placeholder={`{
|
||||||
|
"Mode": "dnsrr",
|
||||||
|
"Ports": [
|
||||||
|
{
|
||||||
|
"Protocol": "tcp",
|
||||||
|
"TargetPort": 5432,
|
||||||
|
"PublishedPort": 5432,
|
||||||
|
"PublishMode": "host"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`}
|
||||||
|
className="h-[17rem] font-mono"
|
||||||
|
{...field}
|
||||||
|
value={field?.value || ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<pre>
|
||||||
|
<FormMessage />
|
||||||
|
</pre>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border">
|
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -28,6 +29,13 @@ interface Props {
|
|||||||
|
|
||||||
const AddRedirectSchema = z.object({
|
const AddRedirectSchema = z.object({
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
|
args: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
value: z.string().min(1, "Argument cannot be empty"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||||
@@ -47,22 +55,30 @@ export const AddCommand = ({ applicationId }: Props) => {
|
|||||||
const form = useForm<AddCommand>({
|
const form = useForm<AddCommand>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
command: "",
|
command: "",
|
||||||
|
args: [],
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRedirectSchema),
|
resolver: zodResolver(AddRedirectSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "args",
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.command) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
command: data?.command || "",
|
command: data?.command || "",
|
||||||
|
args: data?.args?.map((arg) => ({ value: arg })) || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
}, [data, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddCommand) => {
|
const onSubmit = async (data: AddCommand) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId,
|
applicationId,
|
||||||
command: data?.command,
|
command: data?.command,
|
||||||
|
args: data?.args?.map((arg) => arg.value).filter(Boolean),
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Command Updated");
|
toast.success("Command Updated");
|
||||||
@@ -100,13 +116,65 @@ export const AddCommand = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Command</FormLabel>
|
<FormLabel>Command</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Custom command" {...field} />
|
<Input placeholder="/bin/sh" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Arguments (Args)</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => append({ value: "" })}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Argument
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No arguments added yet. Click "Add Argument" to add one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
control={form.control}
|
||||||
|
name={`args.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
index === 0 ? "-c" : "echo Hello World"
|
||||||
|
}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Server } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
applicationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
buildServerId: z.string().min(1, "Build server is required"),
|
||||||
|
buildRegistryId: z.string().min(1, "Build registry is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Schema = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||||
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
|
{ applicationId },
|
||||||
|
{ enabled: !!applicationId },
|
||||||
|
);
|
||||||
|
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||||
|
const { data: registries } = api.registry.all.useQuery();
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<Schema>({
|
||||||
|
defaultValues: {
|
||||||
|
buildServerId: data?.buildServerId || "",
|
||||||
|
buildRegistryId: data?.buildRegistryId || "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
form.reset({
|
||||||
|
buildServerId: data?.buildServerId || "",
|
||||||
|
buildRegistryId: data?.buildRegistryId || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form, form.reset, data]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: Schema) => {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId,
|
||||||
|
buildServerId:
|
||||||
|
formData?.buildServerId === "none" || !formData?.buildServerId
|
||||||
|
? null
|
||||||
|
: formData?.buildServerId,
|
||||||
|
buildRegistryId:
|
||||||
|
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
|
||||||
|
? null
|
||||||
|
: formData?.buildRegistryId,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Build Server Settings Updated");
|
||||||
|
await refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error updating build server settings");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<Server className="size-6 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl">Build Server</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure a dedicated server for building your application.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<AlertBlock type="info">
|
||||||
|
Build servers offload the build process from your deployment servers.
|
||||||
|
Select a build server and registry to use for building your
|
||||||
|
application.
|
||||||
|
</AlertBlock>
|
||||||
|
|
||||||
|
<AlertBlock type="info">
|
||||||
|
📊 <strong>Important:</strong> Once the build finishes, you'll need to
|
||||||
|
wait a few seconds for the deployment server to download the image.
|
||||||
|
These download logs will <strong>NOT</strong> appear in the build
|
||||||
|
deployment logs. Check the <strong>Logs</strong> tab to see when the
|
||||||
|
container starts running.
|
||||||
|
</AlertBlock>
|
||||||
|
|
||||||
|
{!registries || registries.length === 0 ? (
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
You need to add at least one registry to use build servers. Please
|
||||||
|
go to{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/registry"
|
||||||
|
className="text-primary underline"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>{" "}
|
||||||
|
to add a registry.
|
||||||
|
</AlertBlock>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="buildServerId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Build Server</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || "none"}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a build server" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="none">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>None</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{buildServers?.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 justify-between w-full">
|
||||||
|
<span>{server.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{server.ipAddress}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectLabel>
|
||||||
|
Build Servers ({buildServers?.length || 0})
|
||||||
|
</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Select a build server to handle the build process for this
|
||||||
|
application.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="buildRegistryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Build Registry</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || "none"}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a registry" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="none">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>None</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{registries?.map((registry) => (
|
||||||
|
<SelectItem
|
||||||
|
key={registry.registryId}
|
||||||
|
value={registry.registryId}
|
||||||
|
>
|
||||||
|
{registry.registryName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectLabel>
|
||||||
|
Registries ({registries?.length || 0})
|
||||||
|
</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Select a registry to store the built images from the build
|
||||||
|
server.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Button isLoading={isLoading} type="submit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -59,7 +59,13 @@ const mySchema = z.discriminatedUnion("type", [
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("volume"),
|
type: z.literal("volume"),
|
||||||
volumeName: z.string().min(1, "Volume name required"),
|
volumeName: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Volume name required")
|
||||||
|
.regex(
|
||||||
|
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||||||
|
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.merge(mountSchema),
|
.merge(mountSchema),
|
||||||
z
|
z
|
||||||
|
|||||||
@@ -41,7 +41,13 @@ const mySchema = z.discriminatedUnion("type", [
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("volume"),
|
type: z.literal("volume"),
|
||||||
volumeName: z.string().min(1, "Volume name required"),
|
volumeName: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Volume name required")
|
||||||
|
.regex(
|
||||||
|
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||||||
|
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.merge(mountSchema),
|
.merge(mountSchema),
|
||||||
z
|
z
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Scissors } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const KillBuild = ({ id, type }: Props) => {
|
||||||
|
const { mutateAsync, isLoading } =
|
||||||
|
type === "application"
|
||||||
|
? api.application.killBuild.useMutation()
|
||||||
|
: api.compose.killBuild.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-fit" isLoading={isLoading}>
|
||||||
|
Kill Build
|
||||||
|
<Scissors className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure to kill the build?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will kill the build process
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
composeId: id || "",
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Build killed successfully");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,12 @@
|
|||||||
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
RefreshCcw,
|
||||||
|
RocketIcon,
|
||||||
|
Settings,
|
||||||
|
} from "lucide-react";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
@@ -17,6 +25,7 @@ import {
|
|||||||
import { api, type RouterOutputs } from "@/utils/api";
|
import { api, type RouterOutputs } from "@/utils/api";
|
||||||
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
||||||
import { CancelQueues } from "./cancel-queues";
|
import { CancelQueues } from "./cancel-queues";
|
||||||
|
import { KillBuild } from "./kill-build";
|
||||||
import { RefreshToken } from "./refresh-token";
|
import { RefreshToken } from "./refresh-token";
|
||||||
import { ShowDeployment } from "./show-deployment";
|
import { ShowDeployment } from "./show-deployment";
|
||||||
|
|
||||||
@@ -80,6 +89,23 @@ export const ShowDeployments = ({
|
|||||||
} = api.compose.cancelDeployment.useMutation();
|
} = api.compose.cancelDeployment.useMutation();
|
||||||
|
|
||||||
const [url, setUrl] = React.useState("");
|
const [url, setUrl] = React.useState("");
|
||||||
|
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const MAX_DESCRIPTION_LENGTH = 200;
|
||||||
|
|
||||||
|
const truncateDescription = (description: string): string => {
|
||||||
|
if (description.length <= MAX_DESCRIPTION_LENGTH) {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH);
|
||||||
|
const lastSpace = truncated.lastIndexOf(" ");
|
||||||
|
if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) {
|
||||||
|
return `${truncated.slice(0, lastSpace)}...`;
|
||||||
|
}
|
||||||
|
return `${truncated}...`;
|
||||||
|
};
|
||||||
|
|
||||||
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
|
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
|
||||||
const stuckDeployment = useMemo(() => {
|
const stuckDeployment = useMemo(() => {
|
||||||
@@ -118,6 +144,9 @@ export const ShowDeployments = ({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
{(type === "application" || type === "compose") && (
|
||||||
|
<KillBuild id={id} type={type} />
|
||||||
|
)}
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<CancelQueues id={id} type={type} />
|
<CancelQueues id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
@@ -217,122 +246,168 @@ export const ShowDeployments = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{deployments?.map((deployment, index) => (
|
{deployments?.map((deployment, index) => {
|
||||||
<div
|
const titleText = deployment?.title?.trim() || "";
|
||||||
key={deployment.deploymentId}
|
const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH;
|
||||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
const isExpanded = expandedDescriptions.has(
|
||||||
>
|
deployment.deploymentId,
|
||||||
<div className="flex flex-col">
|
);
|
||||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
|
||||||
{index + 1}. {deployment.status}
|
return (
|
||||||
<StatusTooltip
|
<div
|
||||||
status={deployment?.status}
|
key={deployment.deploymentId}
|
||||||
className="size-2.5"
|
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||||
/>
|
>
|
||||||
</span>
|
<div className="flex flex-col">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||||
{deployment.title}
|
{index + 1}. {deployment.status}
|
||||||
</span>
|
<StatusTooltip
|
||||||
{deployment.description && (
|
status={deployment?.status}
|
||||||
<span className="break-all text-sm text-muted-foreground">
|
className="size-2.5"
|
||||||
{deployment.description}
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
|
||||||
<DateTooltip date={deployment.createdAt} />
|
|
||||||
{deployment.startedAt && deployment.finishedAt && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] gap-1 flex items-center"
|
|
||||||
>
|
|
||||||
<Clock className="size-3" />
|
|
||||||
{formatDuration(
|
|
||||||
Math.floor(
|
|
||||||
(new Date(deployment.finishedAt).getTime() -
|
|
||||||
new Date(deployment.startedAt).getTime()) /
|
|
||||||
1000,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
{deployment.pid && deployment.status === "running" && (
|
<span className="break-words text-sm text-muted-foreground whitespace-pre-wrap">
|
||||||
<DialogAction
|
{isExpanded || !needsTruncation
|
||||||
title="Kill Process"
|
? titleText
|
||||||
description="Are you sure you want to kill the process?"
|
: truncateDescription(titleText)}
|
||||||
type="default"
|
</span>
|
||||||
onClick={async () => {
|
{needsTruncation && (
|
||||||
await killProcess({
|
<button
|
||||||
deploymentId: deployment.deploymentId,
|
type="button"
|
||||||
})
|
onClick={() => {
|
||||||
.then(() => {
|
const next = new Set(expandedDescriptions);
|
||||||
toast.success("Process killed successfully");
|
if (next.has(deployment.deploymentId)) {
|
||||||
})
|
next.delete(deployment.deploymentId);
|
||||||
.catch(() => {
|
} else {
|
||||||
toast.error("Error killing process");
|
next.add(deployment.deploymentId);
|
||||||
});
|
}
|
||||||
}}
|
setExpandedDescriptions(next);
|
||||||
>
|
}}
|
||||||
<Button
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mt-1 cursor-pointer"
|
||||||
variant="destructive"
|
aria-label={
|
||||||
size="sm"
|
isExpanded
|
||||||
isLoading={isKillingProcess}
|
? "Collapse commit message"
|
||||||
|
: "Expand commit message"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Kill Process
|
{isExpanded ? (
|
||||||
</Button>
|
<>
|
||||||
</DialogAction>
|
<ChevronUp className="size-3" />
|
||||||
)}
|
Show less
|
||||||
<Button
|
</>
|
||||||
onClick={() => {
|
) : (
|
||||||
setActiveLog(deployment);
|
<>
|
||||||
}}
|
<ChevronDown className="size-3" />
|
||||||
>
|
Show more
|
||||||
View
|
</>
|
||||||
</Button>
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Hash (from description) - shown in compact form */}
|
||||||
|
{deployment.description?.trim() && (
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
|
{deployment.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
|
||||||
|
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||||
|
<DateTooltip date={deployment.createdAt} />
|
||||||
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] gap-1 flex items-center"
|
||||||
|
>
|
||||||
|
<Clock className="size-3" />
|
||||||
|
{formatDuration(
|
||||||
|
Math.floor(
|
||||||
|
(new Date(deployment.finishedAt).getTime() -
|
||||||
|
new Date(deployment.startedAt).getTime()) /
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{deployment?.rollback &&
|
<div className="flex flex-row items-center gap-2">
|
||||||
deployment.status === "done" &&
|
{deployment.pid && deployment.status === "running" && (
|
||||||
type === "application" && (
|
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Rollback to this deployment"
|
title="Kill Process"
|
||||||
description="Are you sure you want to rollback to this deployment?"
|
description="Are you sure you want to kill the process?"
|
||||||
type="default"
|
type="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await rollback({
|
await killProcess({
|
||||||
rollbackId: deployment.rollback.rollbackId,
|
deploymentId: deployment.deploymentId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(
|
toast.success("Process killed successfully");
|
||||||
"Rollback initiated successfully",
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error initiating rollback");
|
toast.error("Error killing process");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isRollingBack}
|
isLoading={isKillingProcess}
|
||||||
>
|
>
|
||||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
Kill Process
|
||||||
Rollback
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveLog(deployment);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{deployment?.rollback &&
|
||||||
|
deployment.status === "done" &&
|
||||||
|
type === "application" && (
|
||||||
|
<DialogAction
|
||||||
|
title="Rollback to this deployment"
|
||||||
|
description="Are you sure you want to rollback to this deployment?"
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await rollback({
|
||||||
|
rollbackId: deployment.rollback.rollbackId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
"Rollback initiated successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error initiating rollback");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
isLoading={isRollingBack}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
Rollback
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ShowDeployment
|
<ShowDeployment
|
||||||
serverId={serverId}
|
serverId={activeLog?.buildServerId || serverId}
|
||||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||||
onClose={() => setActiveLog(null)}
|
onClose={() => setActiveLog(null)}
|
||||||
logPath={activeLog?.logPath || ""}
|
logPath={activeLog?.logPath || ""}
|
||||||
|
|||||||
@@ -46,7 +46,13 @@ export type CacheType = "fetch" | "cache";
|
|||||||
|
|
||||||
export const domain = z
|
export const domain = z
|
||||||
.object({
|
.object({
|
||||||
host: z.string().min(1, { message: "Add a hostname" }),
|
host: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Add a hostname" })
|
||||||
|
.refine((val) => val === val.trim(), {
|
||||||
|
message: "Domain name cannot have leading or trailing spaces",
|
||||||
|
})
|
||||||
|
.transform((val) => val.trim()),
|
||||||
path: z.string().min(1).optional(),
|
path: z.string().min(1).optional(),
|
||||||
internalPath: z.string().optional(),
|
internalPath: z.string().optional(),
|
||||||
stripPath: z.boolean().optional(),
|
stripPath: z.boolean().optional(),
|
||||||
@@ -299,6 +305,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
|
{type === "compose" && (
|
||||||
|
<AlertBlock type="info" className="mb-4">
|
||||||
|
Whenever you make changes to domains, remember to redeploy your
|
||||||
|
compose to apply the changes.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
id="hook-form"
|
id="hook-form"
|
||||||
|
|||||||
@@ -182,7 +182,16 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
id={deployment.previewDeploymentId}
|
id={deployment.previewDeploymentId}
|
||||||
type="previewDeployment"
|
type="previewDeployment"
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
/>
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RocketIcon className="size-4" />
|
||||||
|
Deployments
|
||||||
|
</Button>
|
||||||
|
</ShowDeploymentsModal>
|
||||||
|
|
||||||
<AddPreviewDomain
|
<AddPreviewDomain
|
||||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||||
|
|||||||
@@ -47,7 +47,13 @@ const formSchema = z
|
|||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||||
volumeName: z.string().min(1, "Volume name is required"),
|
volumeName: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Volume name is required")
|
||||||
|
.regex(
|
||||||
|
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||||||
|
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||||||
|
),
|
||||||
prefix: z.string(),
|
prefix: z.string(),
|
||||||
keepLatestCount: z.coerce
|
keepLatestCount: z.coerce
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export const ShowVolumeBackups = ({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Schedule volume backups to run automatically at specified
|
Schedule volume backups to run automatically at specified
|
||||||
intervals.
|
intervals
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { debounce } from "lodash";
|
import _ from "lodash";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
@@ -236,7 +236,7 @@ export const RestoreBackup = ({
|
|||||||
const currentDatabaseType = form.watch("databaseType");
|
const currentDatabaseType = form.watch("databaseType");
|
||||||
const metadata = form.watch("metadata");
|
const metadata = form.watch("metadata");
|
||||||
|
|
||||||
const debouncedSetSearch = debounce((value: string) => {
|
const debouncedSetSearch = _.debounce((value: string) => {
|
||||||
setDebouncedSearchTerm(value);
|
setDebouncedSearchTerm(value);
|
||||||
}, 350);
|
}, 350);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FancyAnsi } from "fancy-ansi";
|
import { FancyAnsi } from "fancy-ansi";
|
||||||
import { escapeRegExp } from "lodash";
|
import _ from "lodash";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -47,7 +47,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const htmlContent = fancyAnsi.toHtml(text);
|
const htmlContent = fancyAnsi.toHtml(text);
|
||||||
const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
|
const searchRegex = new RegExp(`(${_.escapeRegExp(term)})`, "gi");
|
||||||
|
|
||||||
const modifiedContent = htmlContent.replace(
|
const modifiedContent = htmlContent.replace(
|
||||||
searchRegex,
|
searchRegex,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -20,6 +21,13 @@ import type { ServiceType } from "../../application/advanced/show-resources";
|
|||||||
const addDockerImage = z.object({
|
const addDockerImage = z.object({
|
||||||
dockerImage: z.string().min(1, "Docker image is required"),
|
dockerImage: z.string().min(1, "Docker image is required"),
|
||||||
command: z.string(),
|
command: z.string(),
|
||||||
|
args: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
value: z.string().min(1, "Argument cannot be empty"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -61,18 +69,25 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
dockerImage: "",
|
dockerImage: "",
|
||||||
command: "",
|
command: "",
|
||||||
|
args: [],
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addDockerImage),
|
resolver: zodResolver(addDockerImage),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "args",
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
dockerImage: data.dockerImage,
|
dockerImage: data.dockerImage,
|
||||||
command: data.command || "",
|
command: data.command || "",
|
||||||
|
args: data.args?.map((arg) => ({ value: arg })) || [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form, form.reset]);
|
}, [data, form]);
|
||||||
|
|
||||||
const onSubmit = async (formData: AddDockerImage) => {
|
const onSubmit = async (formData: AddDockerImage) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -83,6 +98,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
mariadbId: id || "",
|
mariadbId: id || "",
|
||||||
dockerImage: formData?.dockerImage,
|
dockerImage: formData?.dockerImage,
|
||||||
command: formData?.command,
|
command: formData?.command,
|
||||||
|
args: formData?.args?.map((arg) => arg.value).filter(Boolean),
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Custom Command Updated");
|
toast.success("Custom Command Updated");
|
||||||
@@ -128,13 +144,68 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Command</FormLabel>
|
<FormLabel>Command</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Custom command" {...field} />
|
<Input placeholder="/bin/sh" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Arguments (Args)</FormLabel>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => append({ value: "" })}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Add Argument
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No arguments added yet. Click "Add Argument" to add one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<FormField
|
||||||
|
key={field.id}
|
||||||
|
control={form.control}
|
||||||
|
name={`args.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
index === 0
|
||||||
|
? "-c"
|
||||||
|
: "redis-server --port 6379"
|
||||||
|
}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={form.formState.isSubmitting} type="submit">
|
<Button isLoading={form.formState.isSubmitting} type="submit">
|
||||||
Save
|
Save
|
||||||
|
|||||||
@@ -150,8 +150,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
|||||||
placeholder="Frontend"
|
placeholder="Frontend"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value?.trim() || "";
|
const val = e.target.value || "";
|
||||||
const serviceName = slugify(val);
|
const serviceName = slugify(val.trim());
|
||||||
form.setValue("appName", `${slug}-${serviceName}`);
|
form.setValue("appName", `${slug}-${serviceName}`);
|
||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -161,8 +161,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
|||||||
placeholder="Frontend"
|
placeholder="Frontend"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value?.trim() || "";
|
const val = e.target.value || "";
|
||||||
const serviceName = slugify(val);
|
const serviceName = slugify(val.trim());
|
||||||
form.setValue("appName", `${slug}-${serviceName}`);
|
form.setValue("appName", `${slug}-${serviceName}`);
|
||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -395,8 +395,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value?.trim() || "";
|
const val = e.target.value || "";
|
||||||
const serviceName = slugify(val);
|
const serviceName = slugify(val.trim());
|
||||||
form.setValue("appName", `${slug}-${serviceName}`);
|
form.setValue("appName", `${slug}-${serviceName}`);
|
||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
|
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -44,7 +45,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -52,12 +52,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { TimeBadge } from "@/components/ui/time-badge";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { HandleProject } from "./handle-project";
|
import { HandleProject } from "./handle-project";
|
||||||
import { ProjectEnvironment } from "./project-environment";
|
import { ProjectEnvironment } from "./project-environment";
|
||||||
|
|
||||||
export const ShowProjects = () => {
|
export const ShowProjects = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data, isLoading } = api.project.all.useQuery();
|
const { data, isLoading } = api.project.all.useQuery();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { mutateAsync } = api.project.remove.useMutation();
|
const { mutateAsync } = api.project.remove.useMutation();
|
||||||
@@ -135,6 +137,11 @@ export const ShowProjects = () => {
|
|||||||
<BreadcrumbSidebar
|
<BreadcrumbSidebar
|
||||||
list={[{ name: "Projects", href: "/dashboard/projects" }]}
|
list={[{ name: "Projects", href: "/dashboard/projects" }]}
|
||||||
/>
|
/>
|
||||||
|
{!isCloud && (
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<TimeBadge />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
@@ -148,7 +155,6 @@ export const ShowProjects = () => {
|
|||||||
Create and manage your projects
|
Create and manage your projects
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{(auth?.role === "owner" || auth?.canCreateProjects) && (
|
{(auth?.role === "owner" || auth?.canCreateProjects) && (
|
||||||
<div className="">
|
<div className="">
|
||||||
<HandleProject />
|
<HandleProject />
|
||||||
@@ -298,7 +304,13 @@ export const ShowProjects = () => {
|
|||||||
<Link
|
<Link
|
||||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
href={`${
|
||||||
|
domain.https
|
||||||
|
? "https"
|
||||||
|
: "http"
|
||||||
|
}://${domain.host}${
|
||||||
|
domain.path
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{domain.host}
|
{domain.host}
|
||||||
@@ -340,7 +352,13 @@ export const ShowProjects = () => {
|
|||||||
<Link
|
<Link
|
||||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
href={`${
|
||||||
|
domain.https
|
||||||
|
? "https"
|
||||||
|
: "http"
|
||||||
|
}://${domain.host}${
|
||||||
|
domain.path
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{domain.host}
|
{domain.host}
|
||||||
|
|||||||
@@ -49,51 +49,65 @@ export const RequestDistributionChart = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
<div className="w-full h-[200px] overflow-hidden">
|
||||||
<ChartContainer config={chartConfig}>
|
<ResponsiveContainer
|
||||||
<AreaChart
|
width="100%"
|
||||||
accessibilityLayer
|
height="100%"
|
||||||
data={stats || []}
|
className="overflow-hidden"
|
||||||
margin={{
|
>
|
||||||
left: 12,
|
<ChartContainer config={chartConfig}>
|
||||||
right: 12,
|
<AreaChart
|
||||||
}}
|
accessibilityLayer
|
||||||
>
|
data={stats || []}
|
||||||
<CartesianGrid vertical={false} />
|
margin={{
|
||||||
<XAxis
|
top: 10,
|
||||||
dataKey="hour"
|
left: 12,
|
||||||
tickLine={false}
|
right: 12,
|
||||||
axisLine={false}
|
bottom: 0,
|
||||||
tickMargin={8}
|
}}
|
||||||
tickFormatter={(value) =>
|
>
|
||||||
new Date(value).toLocaleTimeString([], {
|
<CartesianGrid vertical={false} />
|
||||||
hour: "2-digit",
|
<XAxis
|
||||||
minute: "2-digit",
|
dataKey="hour"
|
||||||
})
|
tickLine={false}
|
||||||
}
|
axisLine={false}
|
||||||
/>
|
tickMargin={8}
|
||||||
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
|
tickFormatter={(value) =>
|
||||||
<ChartTooltip
|
new Date(value).toLocaleTimeString([], {
|
||||||
cursor={false}
|
hour: "2-digit",
|
||||||
content={<ChartTooltipContent indicator="line" />}
|
minute: "2-digit",
|
||||||
labelFormatter={(value) =>
|
})
|
||||||
new Date(value).toLocaleString([], {
|
}
|
||||||
month: "short",
|
/>
|
||||||
day: "numeric",
|
<YAxis
|
||||||
hour: "2-digit",
|
tickLine={false}
|
||||||
minute: "2-digit",
|
axisLine={false}
|
||||||
})
|
tickMargin={8}
|
||||||
}
|
allowDataOverflow={false}
|
||||||
/>
|
domain={[0, "auto"]}
|
||||||
<Area
|
/>
|
||||||
dataKey="count"
|
<ChartTooltip
|
||||||
type="natural"
|
cursor={false}
|
||||||
fill="hsl(var(--chart-1))"
|
content={<ChartTooltipContent indicator="line" />}
|
||||||
fillOpacity={0.4}
|
labelFormatter={(value) =>
|
||||||
stroke="hsl(var(--chart-1))"
|
new Date(value).toLocaleString([], {
|
||||||
/>
|
month: "short",
|
||||||
</AreaChart>
|
day: "numeric",
|
||||||
</ChartContainer>
|
hour: "2-digit",
|
||||||
</ResponsiveContainer>
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="count"
|
||||||
|
type="monotone"
|
||||||
|
fill="hsl(var(--chart-1))"
|
||||||
|
fillOpacity={0.4}
|
||||||
|
stroke="hsl(var(--chart-1))"
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,13 +51,19 @@ export const ShowRequests = () => {
|
|||||||
const { mutateAsync: updateLogCleanup } =
|
const { mutateAsync: updateLogCleanup } =
|
||||||
api.settings.updateLogCleanup.useMutation();
|
api.settings.updateLogCleanup.useMutation();
|
||||||
const [cronExpression, setCronExpression] = useState<string | null>(null);
|
const [cronExpression, setCronExpression] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Set default date range to last 3 days
|
||||||
|
const getDefaultDateRange = () => {
|
||||||
|
const to = new Date();
|
||||||
|
const from = new Date();
|
||||||
|
from.setDate(from.getDate() - 3);
|
||||||
|
return { from, to };
|
||||||
|
};
|
||||||
|
|
||||||
const [dateRange, setDateRange] = useState<{
|
const [dateRange, setDateRange] = useState<{
|
||||||
from: Date | undefined;
|
from: Date | undefined;
|
||||||
to: Date | undefined;
|
to: Date | undefined;
|
||||||
}>({
|
}>(getDefaultDateRange());
|
||||||
from: undefined,
|
|
||||||
to: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (logCleanupStatus) {
|
if (logCleanupStatus) {
|
||||||
@@ -169,17 +175,13 @@ export const ShowRequests = () => {
|
|||||||
{isActive ? (
|
{isActive ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-end mb-4 gap-2">
|
<div className="flex justify-end mb-4 gap-2">
|
||||||
{(dateRange.from || dateRange.to) && (
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
onClick={() => setDateRange(getDefaultDateRange())}
|
||||||
onClick={() =>
|
className="px-3"
|
||||||
setDateRange({ from: undefined, to: undefined })
|
>
|
||||||
}
|
Reset to Last 3 Days
|
||||||
className="px-3"
|
</Button>
|
||||||
>
|
|
||||||
Clear dates
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const ShowGitProviders = () => {
|
|||||||
) => {
|
) => {
|
||||||
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
|
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
|
||||||
const scope = "api read_user read_repository";
|
const scope = "api read_user read_repository";
|
||||||
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
|
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scopes=${encodeURIComponent(scope)}`;
|
||||||
return authUrl;
|
return authUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -26,13 +33,12 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Popover,
|
||||||
SelectContent,
|
PopoverContent,
|
||||||
SelectItem,
|
PopoverTrigger,
|
||||||
SelectTrigger,
|
} from "@/components/ui/popover";
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
@@ -53,6 +59,8 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [modelPopoverOpen, setModelPopoverOpen] = useState(false);
|
||||||
|
const [modelSearch, setModelSearch] = useState("");
|
||||||
const { data, refetch } = api.ai.one.useQuery(
|
const { data, refetch } = api.ai.one.useQuery(
|
||||||
{
|
{
|
||||||
aiId: aiId || "",
|
aiId: aiId || "",
|
||||||
@@ -77,13 +85,17 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
if (data) {
|
||||||
name: data?.name ?? "",
|
form.reset({
|
||||||
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
|
name: data?.name ?? "",
|
||||||
apiKey: data?.apiKey ?? "",
|
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
|
||||||
model: data?.model ?? "",
|
apiKey: data?.apiKey ?? "",
|
||||||
isEnabled: data?.isEnabled ?? true,
|
model: data?.model ?? "",
|
||||||
});
|
isEnabled: data?.isEnabled ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setModelSearch("");
|
||||||
|
setModelPopoverOpen(false);
|
||||||
}, [aiId, form, data]);
|
}, [aiId, form, data]);
|
||||||
|
|
||||||
const apiUrl = form.watch("apiUrl");
|
const apiUrl = form.watch("apiUrl");
|
||||||
@@ -104,14 +116,6 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const apiUrl = form.watch("apiUrl");
|
|
||||||
const apiKey = form.watch("apiKey");
|
|
||||||
if (apiUrl && apiKey) {
|
|
||||||
form.setValue("model", "");
|
|
||||||
}
|
|
||||||
}, [form.watch("apiUrl"), form.watch("apiKey")]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: Schema) => {
|
const onSubmit = async (data: Schema) => {
|
||||||
try {
|
try {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -131,7 +135,16 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
setOpen(isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
setModelSearch("");
|
||||||
|
setModelPopoverOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger className="" asChild>
|
<DialogTrigger className="" asChild>
|
||||||
{aiId ? (
|
{aiId ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -182,7 +195,17 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>API URL</FormLabel>
|
<FormLabel>API URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="https://api.openai.com/v1" {...field} />
|
<Input
|
||||||
|
placeholder="https://api.openai.com/v1"
|
||||||
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e);
|
||||||
|
// Reset model when user changes API URL
|
||||||
|
if (form.getValues("model")) {
|
||||||
|
form.setValue("model", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The base URL for your AI provider's API
|
The base URL for your AI provider's API
|
||||||
@@ -205,6 +228,13 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
placeholder="sk-..."
|
placeholder="sk-..."
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
{...field}
|
{...field}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e);
|
||||||
|
// Reset model when user changes API Key
|
||||||
|
if (form.getValues("model")) {
|
||||||
|
form.setValue("model", "");
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -232,30 +262,89 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="model"
|
name="model"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
const selectedModel = models.find(
|
||||||
<FormLabel>Model</FormLabel>
|
(m) => m.id === field.value,
|
||||||
<Select
|
);
|
||||||
onValueChange={field.onChange}
|
const filteredModels = models.filter((model) =>
|
||||||
value={field.value || ""}
|
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
||||||
>
|
);
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
// Ensure selected model is always in the filtered list
|
||||||
<SelectValue placeholder="Select a model" />
|
const displayModels =
|
||||||
</SelectTrigger>
|
field.value &&
|
||||||
</FormControl>
|
!filteredModels.find((m) => m.id === field.value) &&
|
||||||
<SelectContent>
|
selectedModel
|
||||||
{models.map((model) => (
|
? [selectedModel, ...filteredModels]
|
||||||
<SelectItem key={model.id} value={model.id}>
|
: filteredModels;
|
||||||
{model.id}
|
|
||||||
</SelectItem>
|
return (
|
||||||
))}
|
<FormItem>
|
||||||
</SelectContent>
|
<FormLabel>Model</FormLabel>
|
||||||
</Select>
|
<Popover
|
||||||
<FormDescription>Select an AI model to use</FormDescription>
|
open={modelPopoverOpen}
|
||||||
<FormMessage />
|
onOpenChange={setModelPopoverOpen}
|
||||||
</FormItem>
|
>
|
||||||
)}
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between",
|
||||||
|
!field.value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{field.value
|
||||||
|
? (selectedModel?.id ?? field.value)
|
||||||
|
: "Select a model"}
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[400px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search models..."
|
||||||
|
value={modelSearch}
|
||||||
|
onValueChange={setModelSearch}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No models found.</CommandEmpty>
|
||||||
|
{displayModels.map((model) => {
|
||||||
|
const isSelected = field.value === model.id;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={model.id}
|
||||||
|
value={model.id}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(model.id);
|
||||||
|
setModelPopoverOpen(false);
|
||||||
|
setModelSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
isSelected
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{model.id}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<FormDescription>
|
||||||
|
Select an AI model to use
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
|||||||
type: z.literal("ntfy"),
|
type: z.literal("ntfy"),
|
||||||
serverUrl: z.string().min(1, { message: "Server URL is required" }),
|
serverUrl: z.string().min(1, { message: "Server URL is required" }),
|
||||||
topic: z.string().min(1, { message: "Topic is required" }),
|
topic: z.string().min(1, { message: "Topic is required" }),
|
||||||
accessToken: z.string().min(1, { message: "Access Token is required" }),
|
accessToken: z.string().optional(),
|
||||||
priority: z.number().min(1).max(5).default(3),
|
priority: z.number().min(1).max(5).default(3),
|
||||||
})
|
})
|
||||||
.merge(notificationBaseSchema),
|
.merge(notificationBaseSchema),
|
||||||
@@ -303,7 +303,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
accessToken: notification.ntfy?.accessToken,
|
accessToken: notification.ntfy?.accessToken || "",
|
||||||
topic: notification.ntfy?.topic,
|
topic: notification.ntfy?.topic,
|
||||||
priority: notification.ntfy?.priority,
|
priority: notification.ntfy?.priority,
|
||||||
serverUrl: notification.ntfy?.serverUrl,
|
serverUrl: notification.ntfy?.serverUrl,
|
||||||
@@ -432,7 +432,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: data.serverUrl,
|
||||||
accessToken: data.accessToken,
|
accessToken: data.accessToken || "",
|
||||||
topic: data.topic,
|
topic: data.topic,
|
||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -1001,8 +1001,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="AzxcvbnmKjhgfdsa..."
|
placeholder="AzxcvbnmKjhgfdsa..."
|
||||||
{...field}
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Optional. Leave blank for public topics.
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -1214,55 +1218,63 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
isLoadingLark
|
isLoadingLark
|
||||||
}
|
}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const isValid = await form.trigger();
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
const data = form.getValues();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (type === "slack") {
|
if (data.type === "slack") {
|
||||||
await testSlackConnection({
|
await testSlackConnection({
|
||||||
webhookUrl: form.getValues("webhookUrl"),
|
webhookUrl: data.webhookUrl,
|
||||||
channel: form.getValues("channel"),
|
channel: data.channel,
|
||||||
});
|
});
|
||||||
} else if (type === "telegram") {
|
} else if (data.type === "telegram") {
|
||||||
await testTelegramConnection({
|
await testTelegramConnection({
|
||||||
botToken: form.getValues("botToken"),
|
botToken: data.botToken,
|
||||||
chatId: form.getValues("chatId"),
|
chatId: data.chatId,
|
||||||
messageThreadId: form.getValues("messageThreadId") || "",
|
messageThreadId: data.messageThreadId || "",
|
||||||
});
|
});
|
||||||
} else if (type === "discord") {
|
} else if (data.type === "discord") {
|
||||||
await testDiscordConnection({
|
await testDiscordConnection({
|
||||||
webhookUrl: form.getValues("webhookUrl"),
|
webhookUrl: data.webhookUrl,
|
||||||
decoration: form.getValues("decoration"),
|
decoration: data.decoration,
|
||||||
});
|
});
|
||||||
} else if (type === "email") {
|
} else if (data.type === "email") {
|
||||||
await testEmailConnection({
|
await testEmailConnection({
|
||||||
smtpServer: form.getValues("smtpServer"),
|
smtpServer: data.smtpServer,
|
||||||
smtpPort: form.getValues("smtpPort"),
|
smtpPort: data.smtpPort,
|
||||||
username: form.getValues("username"),
|
username: data.username,
|
||||||
password: form.getValues("password"),
|
password: data.password,
|
||||||
toAddresses: form.getValues("toAddresses"),
|
fromAddress: data.fromAddress,
|
||||||
fromAddress: form.getValues("fromAddress"),
|
toAddresses: data.toAddresses,
|
||||||
});
|
});
|
||||||
} else if (type === "gotify") {
|
} else if (data.type === "gotify") {
|
||||||
await testGotifyConnection({
|
await testGotifyConnection({
|
||||||
serverUrl: form.getValues("serverUrl"),
|
serverUrl: data.serverUrl,
|
||||||
appToken: form.getValues("appToken"),
|
appToken: data.appToken,
|
||||||
priority: form.getValues("priority"),
|
priority: data.priority,
|
||||||
decoration: form.getValues("decoration"),
|
decoration: data.decoration,
|
||||||
});
|
});
|
||||||
} else if (type === "ntfy") {
|
} else if (data.type === "ntfy") {
|
||||||
await testNtfyConnection({
|
await testNtfyConnection({
|
||||||
serverUrl: form.getValues("serverUrl"),
|
serverUrl: data.serverUrl,
|
||||||
topic: form.getValues("topic"),
|
topic: data.topic,
|
||||||
accessToken: form.getValues("accessToken"),
|
accessToken: data.accessToken || "",
|
||||||
priority: form.getValues("priority"),
|
priority: data.priority,
|
||||||
});
|
});
|
||||||
} else if (type === "lark") {
|
} else if (data.type === "lark") {
|
||||||
await testLarkConnection({
|
await testLarkConnection({
|
||||||
webhookUrl: form.getValues("webhookUrl"),
|
webhookUrl: data.webhookUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
toast.success("Connection Success");
|
toast.success("Connection Success");
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error("Error testing the provider");
|
toast.error(
|
||||||
|
`Error testing the provider: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const Schema = z.object({
|
|||||||
sshKeyId: z.string().min(1, {
|
sshKeyId: z.string().min(1, {
|
||||||
message: "SSH Key is required",
|
message: "SSH Key is required",
|
||||||
}),
|
}),
|
||||||
|
serverType: z.enum(["deploy", "build"]).default("deploy"),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Schema = z.infer<typeof Schema>;
|
type Schema = z.infer<typeof Schema>;
|
||||||
@@ -89,6 +90,7 @@ export const HandleServers = ({ serverId }: Props) => {
|
|||||||
port: 22,
|
port: 22,
|
||||||
username: "root",
|
username: "root",
|
||||||
sshKeyId: "",
|
sshKeyId: "",
|
||||||
|
serverType: "deploy",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
});
|
});
|
||||||
@@ -101,6 +103,7 @@ export const HandleServers = ({ serverId }: Props) => {
|
|||||||
port: data?.port || 22,
|
port: data?.port || 22,
|
||||||
username: data?.username || "root",
|
username: data?.username || "root",
|
||||||
sshKeyId: data?.sshKeyId || "",
|
sshKeyId: data?.sshKeyId || "",
|
||||||
|
serverType: data?.serverType || "deploy",
|
||||||
});
|
});
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||||
|
|
||||||
@@ -116,6 +119,7 @@ export const HandleServers = ({ serverId }: Props) => {
|
|||||||
port: data.port || 22,
|
port: data.port || 22,
|
||||||
username: data.username || "root",
|
username: data.username || "root",
|
||||||
sshKeyId: data.sshKeyId || "",
|
sshKeyId: data.sshKeyId || "",
|
||||||
|
serverType: data.serverType || "deploy",
|
||||||
serverId: serverId || "",
|
serverId: serverId || "",
|
||||||
})
|
})
|
||||||
.then(async (_data) => {
|
.then(async (_data) => {
|
||||||
@@ -266,6 +270,50 @@ export const HandleServers = ({ serverId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serverType"
|
||||||
|
render={({ field }) => {
|
||||||
|
const serverTypeValue = form.watch("serverType");
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Server Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a server type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="deploy">Deploy Server</SelectItem>
|
||||||
|
<SelectItem value="build">Build Server</SelectItem>
|
||||||
|
<SelectLabel>Server Type</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
{serverTypeValue === "deploy" && (
|
||||||
|
<AlertBlock type="info" className="mt-2">
|
||||||
|
Deploy servers are used to run your applications,
|
||||||
|
databases, and services. They handle the deployment and
|
||||||
|
execution of your projects.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
{serverTypeValue === "build" && (
|
||||||
|
<AlertBlock type="info" className="mt-2">
|
||||||
|
Build servers are dedicated to building your
|
||||||
|
applications. They handle the compilation and build
|
||||||
|
process, offloading this work from your deployment
|
||||||
|
servers. Build servers won't appear in deployment
|
||||||
|
options.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="sshKeyId"
|
name="sshKeyId"
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const SetupServer = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
const isBuildServer = server?.serverType === "build";
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
const [isDeploying, setIsDeploying] = useState(false);
|
const [isDeploying, setIsDeploying] = useState(false);
|
||||||
@@ -117,17 +118,26 @@ export const SetupServer = ({ serverId }: Props) => {
|
|||||||
<TabsList
|
<TabsList
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid w-[700px]",
|
"grid w-[700px]",
|
||||||
isCloud ? "grid-cols-6" : "grid-cols-5",
|
isBuildServer
|
||||||
|
? "grid-cols-3"
|
||||||
|
: isCloud
|
||||||
|
? "grid-cols-6"
|
||||||
|
: "grid-cols-5",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
<TabsTrigger value="validate">Validate</TabsTrigger>
|
<TabsTrigger value="validate">Validate</TabsTrigger>
|
||||||
<TabsTrigger value="audit">Security</TabsTrigger>
|
|
||||||
{isCloud && (
|
{!isBuildServer && (
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
<>
|
||||||
|
<TabsTrigger value="audit">Security</TabsTrigger>
|
||||||
|
{isCloud && (
|
||||||
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
|
)}
|
||||||
|
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="ssh-keys"
|
value="ssh-keys"
|
||||||
@@ -311,32 +321,36 @@ export const SetupServer = ({ serverId }: Props) => {
|
|||||||
<ValidateServer serverId={serverId} />
|
<ValidateServer serverId={serverId} />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent
|
{!isBuildServer && (
|
||||||
value="audit"
|
<>
|
||||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
<TabsContent
|
||||||
>
|
value="audit"
|
||||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
<SecurityAudit serverId={serverId} />
|
>
|
||||||
</div>
|
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||||
</TabsContent>
|
<SecurityAudit serverId={serverId} />
|
||||||
<TabsContent
|
</div>
|
||||||
value="monitoring"
|
</TabsContent>
|
||||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
<TabsContent
|
||||||
>
|
value="monitoring"
|
||||||
<div className="flex flex-col gap-2 text-sm pt-3">
|
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
<div className="rounded-xl bg-background shadow-md border">
|
>
|
||||||
<SetupMonitoring serverId={serverId} />
|
<div className="flex flex-col gap-2 text-sm pt-3">
|
||||||
</div>
|
<div className="rounded-xl bg-background shadow-md border">
|
||||||
</div>
|
<SetupMonitoring serverId={serverId} />
|
||||||
</TabsContent>
|
</div>
|
||||||
<TabsContent
|
</div>
|
||||||
value="gpu-setup"
|
</TabsContent>
|
||||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
<TabsContent
|
||||||
>
|
value="gpu-setup"
|
||||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
<GPUSupport serverId={serverId} />
|
>
|
||||||
</div>
|
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||||
</TabsContent>
|
<GPUSupport serverId={serverId} />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ export const ShowServers = () => {
|
|||||||
Status
|
Status
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
|
<TableHead className="text-center">
|
||||||
|
Type
|
||||||
|
</TableHead>
|
||||||
<TableHead className="text-center">
|
<TableHead className="text-center">
|
||||||
IP Address
|
IP Address
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -153,6 +156,8 @@ export const ShowServers = () => {
|
|||||||
{data?.map((server) => {
|
{data?.map((server) => {
|
||||||
const canDelete = server.totalSum === 0;
|
const canDelete = server.totalSum === 0;
|
||||||
const isActive = server.serverStatus === "active";
|
const isActive = server.serverStatus === "active";
|
||||||
|
const isBuildServer =
|
||||||
|
server.serverType === "build";
|
||||||
return (
|
return (
|
||||||
<TableRow key={server.serverId}>
|
<TableRow key={server.serverId}>
|
||||||
<TableCell className="text-left">
|
<TableCell className="text-left">
|
||||||
@@ -171,6 +176,15 @@ export const ShowServers = () => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
isBuildServer ? "secondary" : "default"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{server.serverType}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<Badge>{server.ipAddress}</Badge>
|
<Badge>{server.ipAddress}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -233,11 +247,12 @@ export const ShowServers = () => {
|
|||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{server.sshKeyId && (
|
{server.sshKeyId &&
|
||||||
<ShowServerActions
|
!isBuildServer && (
|
||||||
serverId={server.serverId}
|
<ShowServerActions
|
||||||
/>
|
serverId={server.serverId}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -286,41 +301,43 @@ export const ShowServers = () => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
|
|
||||||
{isActive && server.sshKeyId && (
|
{isActive &&
|
||||||
<>
|
server.sshKeyId &&
|
||||||
<DropdownMenuSeparator />
|
!isBuildServer && (
|
||||||
<DropdownMenuLabel>
|
<>
|
||||||
Extra
|
<DropdownMenuSeparator />
|
||||||
</DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
|
Extra
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<ShowTraefikFileSystemModal
|
<ShowTraefikFileSystemModal
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
|
||||||
<ShowDockerContainersModal
|
|
||||||
serverId={server.serverId}
|
|
||||||
/>
|
|
||||||
{isCloud && (
|
|
||||||
<ShowMonitoringModal
|
|
||||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
|
||||||
token={
|
|
||||||
server?.metricsConfig?.server
|
|
||||||
?.token
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<ShowDockerContainersModal
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
{isCloud && (
|
||||||
|
<ShowMonitoringModal
|
||||||
|
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||||
|
token={
|
||||||
|
server?.metricsConfig
|
||||||
|
?.server?.token
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ShowSwarmOverviewModal
|
<ShowSwarmOverviewModal
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
/>
|
||||||
<ShowNodesModal
|
<ShowNodesModal
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShowSchedulesModal
|
<ShowSchedulesModal
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
enabled: !!serverId,
|
enabled: !!serverId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const { data: server } = api.server.one.useQuery(
|
||||||
|
{ serverId },
|
||||||
|
{
|
||||||
|
enabled: !!serverId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const isBuildServer = server?.serverType === "build";
|
||||||
const _utils = api.useUtils();
|
const _utils = api.useUtils();
|
||||||
return (
|
return (
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -73,7 +80,9 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
<div className="border rounded-lg p-4">
|
<div className="border rounded-lg p-4">
|
||||||
<h3 className="text-lg font-semibold mb-1">Status</h3>
|
<h3 className="text-lg font-semibold mb-1">Status</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Shows the server configuration status
|
{isBuildServer
|
||||||
|
? "Shows the build server configuration status"
|
||||||
|
: "Shows the server configuration status"}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2.5">
|
<div className="grid gap-2.5">
|
||||||
<StatusRow
|
<StatusRow
|
||||||
@@ -85,15 +94,17 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StatusRow
|
{!isBuildServer && (
|
||||||
label="RClone Installed"
|
<StatusRow
|
||||||
isEnabled={data?.rclone?.enabled}
|
label="RClone Installed"
|
||||||
description={
|
isEnabled={data?.rclone?.enabled}
|
||||||
data?.rclone?.enabled
|
description={
|
||||||
? `Installed: ${data?.rclone?.version}`
|
data?.rclone?.enabled
|
||||||
: undefined
|
? `Installed: ${data?.rclone?.version}`
|
||||||
}
|
: undefined
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<StatusRow
|
<StatusRow
|
||||||
label="Nixpacks Installed"
|
label="Nixpacks Installed"
|
||||||
isEnabled={data?.nixpacks?.enabled}
|
isEnabled={data?.nixpacks?.enabled}
|
||||||
@@ -113,23 +124,36 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StatusRow
|
<StatusRow
|
||||||
label="Docker Swarm Initialized"
|
label="Railpack Installed"
|
||||||
isEnabled={data?.isSwarmInstalled}
|
isEnabled={data?.railpack?.enabled}
|
||||||
description={
|
description={
|
||||||
data?.isSwarmInstalled
|
data?.railpack?.enabled
|
||||||
? "Initialized"
|
? `Installed: ${data?.railpack?.version}`
|
||||||
: "Not Initialized"
|
: undefined
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusRow
|
|
||||||
label="Dokploy Network Created"
|
|
||||||
isEnabled={data?.isDokployNetworkInstalled}
|
|
||||||
description={
|
|
||||||
data?.isDokployNetworkInstalled
|
|
||||||
? "Created"
|
|
||||||
: "Not Created"
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{!isBuildServer && (
|
||||||
|
<>
|
||||||
|
<StatusRow
|
||||||
|
label="Docker Swarm Initialized"
|
||||||
|
isEnabled={data?.isSwarmInstalled}
|
||||||
|
description={
|
||||||
|
data?.isSwarmInstalled
|
||||||
|
? "Initialized"
|
||||||
|
: "Not Initialized"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Dokploy Network Created"
|
||||||
|
isEnabled={data?.isDokployNetworkInstalled}
|
||||||
|
description={
|
||||||
|
data?.isDokployNetworkInstalled
|
||||||
|
? "Created"
|
||||||
|
: "Not Created"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<StatusRow
|
<StatusRow
|
||||||
label="Main Directory Created"
|
label="Main Directory Created"
|
||||||
isEnabled={data?.isMainDirectoryInstalled}
|
isEnabled={data?.isMainDirectoryInstalled}
|
||||||
@@ -139,15 +163,6 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
: "Not Created"
|
: "Not Created"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StatusRow
|
|
||||||
label="Railpack Installed"
|
|
||||||
isEnabled={data?.railpack?.enabled}
|
|
||||||
description={
|
|
||||||
data?.railpack?.enabled
|
|
||||||
? `Installed: ${data?.railpack?.version}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export const CreateServer = ({ stepper }: Props) => {
|
|||||||
port: data.port || 22,
|
port: data.port || 22,
|
||||||
username: data.username || "root",
|
username: data.username || "root",
|
||||||
sshKeyId: data.sshKeyId || "",
|
sshKeyId: data.sshKeyId || "",
|
||||||
|
serverType: "deploy",
|
||||||
})
|
})
|
||||||
.then(async (_data) => {
|
.then(async (_data) => {
|
||||||
toast.success("Server Created");
|
toast.success("Server Created");
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { findEnvironmentById } from "@dokploy/server/index";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -27,12 +26,10 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api, type RouterOutputs } from "@/utils/api";
|
||||||
|
|
||||||
type Environment = Omit<
|
type Project = RouterOutputs["project"]["all"][number];
|
||||||
Awaited<ReturnType<typeof findEnvironmentById>>,
|
type Environment = Project["environments"][number];
|
||||||
"project"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type Services = {
|
export type Services = {
|
||||||
appName: string;
|
appName: string;
|
||||||
@@ -53,17 +50,16 @@ export type Services = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const extractServices = (data: Environment | undefined) => {
|
export const extractServices = (data: Environment | undefined) => {
|
||||||
const applications: Services[] =
|
const applications: Services[] = (data?.applications?.map((item) => ({
|
||||||
data?.applications.map((item) => ({
|
appName: item.appName,
|
||||||
appName: item.appName,
|
name: item.name,
|
||||||
name: item.name,
|
type: "application",
|
||||||
type: "application",
|
id: item.applicationId,
|
||||||
id: item.applicationId,
|
createdAt: item.createdAt,
|
||||||
createdAt: item.createdAt,
|
status: item.applicationStatus,
|
||||||
status: item.applicationStatus,
|
description: item.description,
|
||||||
description: item.description,
|
serverId: item.serverId,
|
||||||
serverId: item.serverId,
|
})) ?? []) as Services[];
|
||||||
})) || [];
|
|
||||||
|
|
||||||
const mariadb: Services[] =
|
const mariadb: Services[] =
|
||||||
data?.mariadb.map((item) => ({
|
data?.mariadb.map((item) => ({
|
||||||
@@ -125,17 +121,16 @@ export const extractServices = (data: Environment | undefined) => {
|
|||||||
serverId: item.serverId,
|
serverId: item.serverId,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const compose: Services[] =
|
const compose: Services[] = (data?.compose?.map((item) => ({
|
||||||
data?.compose.map((item) => ({
|
appName: item.appName,
|
||||||
appName: item.appName,
|
name: item.name,
|
||||||
name: item.name,
|
type: "compose",
|
||||||
type: "compose",
|
id: item.composeId,
|
||||||
id: item.composeId,
|
createdAt: item.createdAt,
|
||||||
createdAt: item.createdAt,
|
status: item.composeStatus,
|
||||||
status: item.composeStatus,
|
description: item.description,
|
||||||
description: item.description,
|
serverId: item.serverId,
|
||||||
serverId: item.serverId,
|
})) ?? []) as Services[];
|
||||||
})) || [];
|
|
||||||
|
|
||||||
applications.push(
|
applications.push(
|
||||||
...mysql,
|
...mysql,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableCaption,
|
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
@@ -68,7 +67,6 @@ export const ShowUsers = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
<Table>
|
<Table>
|
||||||
<TableCaption>See all users</TableCaption>
|
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[100px]">Email</TableHead>
|
<TableHead className="w-[100px]">Email</TableHead>
|
||||||
@@ -111,35 +109,75 @@ export const ShowUsers = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="text-right flex justify-end">
|
<TableCell className="text-right flex justify-end">
|
||||||
<DropdownMenu>
|
{member.role !== "owner" && (
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<Button
|
<DropdownMenuTrigger asChild>
|
||||||
variant="ghost"
|
<Button
|
||||||
className="h-8 w-8 p-0"
|
variant="ghost"
|
||||||
>
|
className="h-8 w-8 p-0"
|
||||||
<span className="sr-only">Open menu</span>
|
>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<span className="sr-only">
|
||||||
</Button>
|
Open menu
|
||||||
</DropdownMenuTrigger>
|
</span>
|
||||||
<DropdownMenuContent align="end">
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
<DropdownMenuLabel>
|
</Button>
|
||||||
Actions
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenuLabel>
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
Actions
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
{member.role !== "owner" && (
|
|
||||||
<AddUserPermissions
|
<AddUserPermissions
|
||||||
userId={member.user.id}
|
userId={member.user.id}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{member.role !== "owner" && (
|
{!isCloud && (
|
||||||
<>
|
<DialogAction
|
||||||
{!isCloud && (
|
title="Delete User"
|
||||||
<DialogAction
|
description="Are you sure you want to delete this user?"
|
||||||
title="Delete User"
|
type="destructive"
|
||||||
description="Are you sure you want to delete this user?"
|
onClick={async () => {
|
||||||
type="destructive"
|
await mutateAsync({
|
||||||
onClick={async () => {
|
userId: member.user.id,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
"User deleted successfully",
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(
|
||||||
|
"Error deleting destination",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogAction
|
||||||
|
title="Unlink User"
|
||||||
|
description="Are you sure you want to unlink this user?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!isCloud) {
|
||||||
|
const orgCount =
|
||||||
|
await utils.user.checkUserOrganizations.fetch(
|
||||||
|
{
|
||||||
|
userId: member.user.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(orgCount);
|
||||||
|
|
||||||
|
if (orgCount === 1) {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
userId: member.user.id,
|
userId: member.user.id,
|
||||||
})
|
})
|
||||||
@@ -151,86 +189,40 @@ export const ShowUsers = () => {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Error deleting destination",
|
"Error deleting user",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}}
|
return;
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
|
||||||
onSelect={(e) =>
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Delete User
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogAction>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogAction
|
|
||||||
title="Unlink User"
|
|
||||||
description="Are you sure you want to unlink this user?"
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
if (!isCloud) {
|
|
||||||
const orgCount =
|
|
||||||
await utils.user.checkUserOrganizations.fetch(
|
|
||||||
{
|
|
||||||
userId: member.user.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(orgCount);
|
|
||||||
|
|
||||||
if (orgCount === 1) {
|
|
||||||
await mutateAsync({
|
|
||||||
userId: member.user.id,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(
|
|
||||||
"User deleted successfully",
|
|
||||||
);
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(
|
|
||||||
"Error deleting user",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { error } =
|
const { error } =
|
||||||
await authClient.organization.removeMember(
|
await authClient.organization.removeMember(
|
||||||
{
|
{
|
||||||
memberIdOrEmail: member.id,
|
memberIdOrEmail: member.id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
toast.success(
|
toast.success(
|
||||||
"User unlinked successfully",
|
"User unlinked successfully",
|
||||||
);
|
);
|
||||||
refetch();
|
refetch();
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error("Error unlinking user");
|
||||||
"Error unlinking user",
|
}
|
||||||
);
|
}}
|
||||||
}
|
>
|
||||||
}}
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
Unlink User
|
||||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
</DropdownMenuItem>
|
||||||
onSelect={(e) => e.preventDefault()}
|
</DialogAction>
|
||||||
>
|
</DropdownMenuContent>
|
||||||
Unlink User
|
</DropdownMenu>
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
</DialogAction>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
PieChart,
|
PieChart,
|
||||||
Server,
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
@@ -82,6 +83,7 @@ import { AddOrganization } from "../dashboard/organization/handle-organization";
|
|||||||
import { DialogAction } from "../shared/dialog-action";
|
import { DialogAction } from "../shared/dialog-action";
|
||||||
import { Logo } from "../shared/logo";
|
import { Logo } from "../shared/logo";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
|
import { TimeBadge } from "../ui/time-badge";
|
||||||
import { UpdateServerButton } from "./update-server";
|
import { UpdateServerButton } from "./update-server";
|
||||||
import { UserNav } from "./user-nav";
|
import { UserNav } from "./user-nav";
|
||||||
|
|
||||||
@@ -497,7 +499,6 @@ function SidebarLogo() {
|
|||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data: user } = api.user.get.useQuery();
|
const { data: user } = api.user.get.useQuery();
|
||||||
const { data: session } = authClient.useSession();
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: organizations,
|
data: organizations,
|
||||||
refetch,
|
refetch,
|
||||||
@@ -505,6 +506,8 @@ function SidebarLogo() {
|
|||||||
} = api.organization.all.useQuery();
|
} = api.organization.all.useQuery();
|
||||||
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
|
const { mutateAsync: deleteOrganization, isLoading: isRemoving } =
|
||||||
api.organization.delete.useMutation();
|
api.organization.delete.useMutation();
|
||||||
|
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
|
||||||
|
api.organization.setDefault.useMutation();
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||||
const _utils = api.useUtils();
|
const _utils = api.useUtils();
|
||||||
@@ -594,66 +597,127 @@ function SidebarLogo() {
|
|||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||||
Organizations
|
Organizations
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{organizations?.map((org) => (
|
{organizations?.map((org) => {
|
||||||
<div className="flex flex-row justify-between" key={org.name}>
|
const isDefault = org.members?.[0]?.isDefault ?? false;
|
||||||
<DropdownMenuItem
|
return (
|
||||||
onClick={async () => {
|
<div
|
||||||
await authClient.organization.setActive({
|
className="flex flex-row justify-between"
|
||||||
organizationId: org.id,
|
key={org.name}
|
||||||
});
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
|
||||||
className="w-full gap-2 p-2"
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">{org.name}</div>
|
<DropdownMenuItem
|
||||||
<div className="flex size-6 items-center justify-center rounded-sm border">
|
onClick={async () => {
|
||||||
<Logo
|
await authClient.organization.setActive({
|
||||||
className={cn(
|
organizationId: org.id,
|
||||||
"transition-all",
|
});
|
||||||
state === "collapsed" ? "size-6" : "size-10",
|
window.location.reload();
|
||||||
)}
|
}}
|
||||||
logoUrl={org.logo ?? undefined}
|
className="w-full gap-2 p-2"
|
||||||
/>
|
>
|
||||||
</div>
|
<div className="flex flex-col gap-1">
|
||||||
</DropdownMenuItem>
|
<div className="flex items-center gap-2">
|
||||||
{org.ownerId === session?.user?.id && (
|
{org.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||||
|
<Logo
|
||||||
|
className={cn(
|
||||||
|
"transition-all",
|
||||||
|
state === "collapsed" ? "size-6" : "size-10",
|
||||||
|
)}
|
||||||
|
logoUrl={org.logo ?? undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AddOrganization organizationId={org.id} />
|
<Button
|
||||||
<DialogAction
|
variant="ghost"
|
||||||
title="Delete Organization"
|
size="icon"
|
||||||
description="Are you sure you want to delete this organization?"
|
className={cn(
|
||||||
type="destructive"
|
"group",
|
||||||
onClick={async () => {
|
isDefault
|
||||||
await deleteOrganization({
|
? "hover:bg-yellow-500/10"
|
||||||
|
: "hover:bg-blue-500/10",
|
||||||
|
)}
|
||||||
|
isLoading={isSettingDefault && !isDefault}
|
||||||
|
disabled={isDefault}
|
||||||
|
onClick={async (e) => {
|
||||||
|
if (isDefault) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
await setDefaultOrganization({
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
refetch();
|
refetch();
|
||||||
toast.success(
|
toast.success("Default organization updated");
|
||||||
"Organization deleted successfully",
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
error?.message ||
|
error?.message ||
|
||||||
"Error deleting organization",
|
"Error setting default organization",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
title={
|
||||||
|
isDefault
|
||||||
|
? "Default organization"
|
||||||
|
: "Set as default"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Button
|
{isDefault ? (
|
||||||
variant="ghost"
|
<Star
|
||||||
size="icon"
|
fill="#eab308"
|
||||||
className="group hover:bg-red-500/10"
|
stroke="#eab308"
|
||||||
isLoading={isRemoving}
|
className="size-4 text-yellow-500"
|
||||||
>
|
/>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
) : (
|
||||||
</Button>
|
<Star
|
||||||
</DialogAction>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{org.ownerId === session?.user?.id && (
|
||||||
|
<>
|
||||||
|
<AddOrganization organizationId={org.id} />
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Organization"
|
||||||
|
description="Are you sure you want to delete this organization?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteOrganization({
|
||||||
|
organizationId: org.id,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
"Organization deleted successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
error?.message ||
|
||||||
|
"Error deleting organization",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
{(user?.role === "owner" || isCloud) && (
|
{(user?.role === "owner" || isCloud) && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
@@ -1062,6 +1126,7 @@ export default function Page({ children }: Props) {
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
|
{!isCloud && <TimeBadge />}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -67,9 +67,10 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
className={cn("hidden", className)}
|
className={cn("hidden", className)}
|
||||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
onChange(e.target.files)
|
onChange(e.target.files);
|
||||||
}
|
e.target.value = "";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
64
apps/dokploy/components/ui/time-badge.tsx
Normal file
64
apps/dokploy/components/ui/time-badge.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export function TimeBadge() {
|
||||||
|
const { data: serverTime } = api.server.getServerTime.useQuery(undefined);
|
||||||
|
const [time, setTime] = useState<Date | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (serverTime?.time) {
|
||||||
|
setTime(new Date(serverTime.time));
|
||||||
|
}
|
||||||
|
}, [serverTime]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setTime((prevTime) => {
|
||||||
|
if (!prevTime) return null;
|
||||||
|
const newTime = new Date(prevTime.getTime() + 1000);
|
||||||
|
return newTime;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!time || !serverTime?.timezone) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUtcOffset = (timeZone: string) => {
|
||||||
|
const date = new Date();
|
||||||
|
const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" }));
|
||||||
|
const tzDate = new Date(date.toLocaleString("en-US", { timeZone }));
|
||||||
|
const offset = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60 * 60);
|
||||||
|
const sign = offset >= 0 ? "+" : "-";
|
||||||
|
const hours = Math.floor(Math.abs(offset));
|
||||||
|
const minutes = (Math.abs(offset) * 60) % 60;
|
||||||
|
return `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedTime = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: serverTime.timezone,
|
||||||
|
timeStyle: "medium",
|
||||||
|
hour12: false,
|
||||||
|
}).format(time);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center rounded-full border p-1 text-xs whitespace-nowrap max-w-full overflow-hidden gap-1">
|
||||||
|
<div className="inline-flex items-center px-1 gap-1">
|
||||||
|
<span className="hidden sm:inline">Server Time:</span>
|
||||||
|
<span className="font-medium tabular-nums">{formattedTime}</span>
|
||||||
|
</div>
|
||||||
|
<span className="hidden sm:inline text-primary/70 border rounded-full bg-foreground/5 px-1.5 py-0.5">
|
||||||
|
{serverTime.timezone} | {getUtcOffset(serverTime.timezone)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
apps/dokploy/drizzle/0119_bouncy_morbius.sql
Normal file
1
apps/dokploy/drizzle/0119_bouncy_morbius.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "member" ADD COLUMN "is_default" boolean DEFAULT false NOT NULL;
|
||||||
39
apps/dokploy/drizzle/0120_lame_captain_midlands.sql
Normal file
39
apps/dokploy/drizzle/0120_lame_captain_midlands.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
ALTER TABLE "user_temp" RENAME TO "user";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP CONSTRAINT "user_temp_email_unique";--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_temp_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "apikey" DROP CONSTRAINT "apikey_user_id_user_temp_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "invitation" DROP CONSTRAINT "invitation_inviter_id_user_temp_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "member" DROP CONSTRAINT "member_user_id_user_temp_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "organization" DROP CONSTRAINT "organization_owner_id_user_temp_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "two_factor" DROP CONSTRAINT "two_factor_user_id_user_temp_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" DROP CONSTRAINT "backup_userId_user_temp_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "git_provider" DROP CONSTRAINT "git_provider_userId_user_temp_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "schedule" DROP CONSTRAINT "schedule_userId_user_temp_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "session_temp" DROP CONSTRAINT "session_temp_user_id_user_temp_id_fk";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "application" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||||
|
ALTER TABLE "mariadb" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||||
|
ALTER TABLE "mongo" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||||
|
ALTER TABLE "mysql" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||||
|
ALTER TABLE "postgres" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||||
|
ALTER TABLE "redis" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "apikey" ADD CONSTRAINT "apikey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "organization" ADD CONSTRAINT "organization_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD CONSTRAINT "backup_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "git_provider" ADD CONSTRAINT "git_provider_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session_temp" ADD CONSTRAINT "session_temp_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email");
|
||||||
9
apps/dokploy/drizzle/0121_rainy_cargill.sql
Normal file
9
apps/dokploy/drizzle/0121_rainy_cargill.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Fix inconsistent date formats in environment.createdAt field
|
||||||
|
-- Convert PostgreSQL timestamp format to ISO 8601 format
|
||||||
|
-- This addresses issue #2992 where old environments have PostgreSQL timestamp format
|
||||||
|
-- while new ones have ISO 8601 format
|
||||||
|
|
||||||
|
UPDATE "environment"
|
||||||
|
SET "createdAt" = to_char("createdAt"::timestamptz, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
|
||||||
|
WHERE "createdAt" NOT LIKE '%T%';
|
||||||
|
|
||||||
8
apps/dokploy/drizzle/0122_absent_frightful_four.sql
Normal file
8
apps/dokploy/drizzle/0122_absent_frightful_four.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TYPE "public"."serverType" AS ENUM('deploy', 'build');--> statement-breakpoint
|
||||||
|
ALTER TABLE "application" ADD COLUMN "buildServerId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "application" ADD COLUMN "buildRegistryId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "deployment" ADD COLUMN "buildServerId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "server" ADD COLUMN "serverType" "serverType" DEFAULT 'deploy' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "application" ADD CONSTRAINT "application_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "application" ADD CONSTRAINT "application_buildRegistryId_registry_registryId_fk" FOREIGN KEY ("buildRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
|
||||||
6
apps/dokploy/drizzle/0123_cloudy_piledriver.sql
Normal file
6
apps/dokploy/drizzle/0123_cloudy_piledriver.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
ALTER TABLE "application" ADD COLUMN "args" text[];--> statement-breakpoint
|
||||||
|
ALTER TABLE "mariadb" ADD COLUMN "args" text[];--> statement-breakpoint
|
||||||
|
ALTER TABLE "mongo" ADD COLUMN "args" text[];--> statement-breakpoint
|
||||||
|
ALTER TABLE "mysql" ADD COLUMN "args" text[];--> statement-breakpoint
|
||||||
|
ALTER TABLE "postgres" ADD COLUMN "args" text[];--> statement-breakpoint
|
||||||
|
ALTER TABLE "redis" ADD COLUMN "args" text[];
|
||||||
1
apps/dokploy/drizzle/0124_certain_cloak.sql
Normal file
1
apps/dokploy/drizzle/0124_certain_cloak.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "ntfy" ALTER COLUMN "accessToken" DROP NOT NULL;
|
||||||
6686
apps/dokploy/drizzle/meta/0119_snapshot.json
Normal file
6686
apps/dokploy/drizzle/meta/0119_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6722
apps/dokploy/drizzle/meta/0120_snapshot.json
Normal file
6722
apps/dokploy/drizzle/meta/0120_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6722
apps/dokploy/drizzle/meta/0121_snapshot.json
Normal file
6722
apps/dokploy/drizzle/meta/0121_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6795
apps/dokploy/drizzle/meta/0122_snapshot.json
Normal file
6795
apps/dokploy/drizzle/meta/0122_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6831
apps/dokploy/drizzle/meta/0123_snapshot.json
Normal file
6831
apps/dokploy/drizzle/meta/0123_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6831
apps/dokploy/drizzle/meta/0124_snapshot.json
Normal file
6831
apps/dokploy/drizzle/meta/0124_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -834,6 +834,48 @@
|
|||||||
"when": 1761415824484,
|
"when": 1761415824484,
|
||||||
"tag": "0118_loose_anita_blake",
|
"tag": "0118_loose_anita_blake",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 119,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1762142756443,
|
||||||
|
"tag": "0119_bouncy_morbius",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 120,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1762632540024,
|
||||||
|
"tag": "0120_lame_captain_midlands",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 121,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763755037033,
|
||||||
|
"tag": "0121_rainy_cargill",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 122,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1764479387555,
|
||||||
|
"tag": "0122_absent_frightful_four",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 123,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1764525308939,
|
||||||
|
"tag": "0123_cloudy_piledriver",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 124,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1764571454170,
|
||||||
|
"tag": "0124_certain_cloak",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.25.6",
|
"version": "v0.25.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -34,7 +34,8 @@
|
|||||||
"docker:build:canary": "./docker/build.sh canary",
|
"docker:build:canary": "./docker/build.sh canary",
|
||||||
"docker:push:canary": "./docker/push.sh canary",
|
"docker:push:canary": "./docker/push.sh canary",
|
||||||
"version": "echo $(node -p \"require('./package.json').version\")",
|
"version": "echo $(node -p \"require('./package.json').version\")",
|
||||||
"test": "vitest --config __test__/vitest.config.ts"
|
"test": "vitest --config __test__/vitest.config.ts",
|
||||||
|
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^2.0.5",
|
"@ai-sdk/anthropic": "^2.0.5",
|
||||||
@@ -98,6 +99,7 @@
|
|||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"bullmq": "5.4.2",
|
"bullmq": "5.4.2",
|
||||||
|
"shell-quote": "^1.8.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^0.2.1",
|
"cmdk": "^0.2.1",
|
||||||
@@ -112,7 +114,6 @@
|
|||||||
"i18next": "^23.16.8",
|
"i18next": "^23.16.8",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"yaml": "2.8.1",
|
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
@@ -120,7 +121,8 @@
|
|||||||
"next": "^15.3.2",
|
"next": "^15.3.2",
|
||||||
"next-i18next": "^15.4.2",
|
"next-i18next": "^15.4.2",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"node-os-utils": "1.3.7",
|
"nextjs-toploader": "^3.9.17",
|
||||||
|
"node-os-utils": "2.0.1",
|
||||||
"node-pty": "1.0.0",
|
"node-pty": "1.0.0",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "6.9.14",
|
||||||
@@ -153,17 +155,18 @@
|
|||||||
"use-resize-observer": "9.1.0",
|
"use-resize-observer": "9.1.0",
|
||||||
"ws": "8.16.0",
|
"ws": "8.16.0",
|
||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
|
"yaml": "2.8.1",
|
||||||
"zod": "^3.25.32",
|
"zod": "^3.25.32",
|
||||||
"zod-form-data": "^2.0.7"
|
"zod-form-data": "^2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/lodash": "4.17.4",
|
"@types/lodash": "4.17.4",
|
||||||
"@types/micromatch": "4.0.9",
|
"@types/micromatch": "4.0.9",
|
||||||
"@types/node": "^18.19.104",
|
"@types/node": "^18.19.104",
|
||||||
"@types/node-os-utils": "1.3.4",
|
|
||||||
"@types/node-schedule": "2.1.6",
|
"@types/node-schedule": "2.1.6",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Head from "next/head";
|
|||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { appWithTranslation } from "next-i18next";
|
import { appWithTranslation } from "next-i18next";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
import NextTopLoader from "nextjs-toploader";
|
||||||
import type { ReactElement, ReactNode } from "react";
|
import type { ReactElement, ReactNode } from "react";
|
||||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
@@ -57,6 +58,7 @@ const MyApp = ({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
forcedTheme={Component.theme}
|
forcedTheme={Component.theme}
|
||||||
>
|
>
|
||||||
|
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
<SearchCommand />
|
<SearchCommand />
|
||||||
{getLayout(<Component {...pageProps} />)}
|
{getLayout(<Component {...pageProps} />)}
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ import type { DeploymentJob } from "@/server/queues/queue-types";
|
|||||||
import { myQueue } from "@/server/queues/queueSetup";
|
import { myQueue } from "@/server/queues/queueSetup";
|
||||||
import { deploy } from "@/server/utils/deploy";
|
import { deploy } from "@/server/utils/deploy";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to get package_version from registry_package events
|
||||||
|
*/
|
||||||
|
const getPackageVersion = (headers: any, body: any) => {
|
||||||
|
const event = headers["x-github-event"];
|
||||||
|
if (event === "registry_package") {
|
||||||
|
return body.registry_package?.package_version;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse,
|
res: NextApiResponse,
|
||||||
@@ -46,21 +57,60 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
||||||
const deploymentHash = extractHash(req.headers, req.body);
|
|
||||||
|
|
||||||
|
const deploymentHash = extractHash(req.headers, req.body);
|
||||||
const sourceType = application.sourceType;
|
const sourceType = application.sourceType;
|
||||||
|
|
||||||
if (sourceType === "docker") {
|
if (sourceType === "docker") {
|
||||||
|
const applicationImageName = extractImageName(application.dockerImage);
|
||||||
const applicationDockerTag = extractImageTag(application.dockerImage);
|
const applicationDockerTag = extractImageTag(application.dockerImage);
|
||||||
|
|
||||||
|
const webhookImageName = extractImageNameFromRequest(
|
||||||
|
req.headers,
|
||||||
|
req.body,
|
||||||
|
);
|
||||||
const webhookDockerTag = extractImageTagFromRequest(
|
const webhookDockerTag = extractImageTagFromRequest(
|
||||||
req.headers,
|
req.headers,
|
||||||
req.body,
|
req.body,
|
||||||
);
|
);
|
||||||
if (
|
|
||||||
applicationDockerTag &&
|
if (!applicationImageName) {
|
||||||
webhookDockerTag &&
|
res.status(301).json({
|
||||||
webhookDockerTag !== applicationDockerTag
|
message: "Application Docker Image Name Not Found",
|
||||||
) {
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhookImageName) {
|
||||||
|
res.status(301).json({
|
||||||
|
message: "Webhook Docker Image Name Not Found",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate image name matches
|
||||||
|
if (webhookImageName !== applicationImageName) {
|
||||||
|
res.status(301).json({
|
||||||
|
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!applicationDockerTag) {
|
||||||
|
res.status(301).json({
|
||||||
|
message: "Application Docker Tag Not Found",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhookDockerTag) {
|
||||||
|
res.status(301).json({
|
||||||
|
message: "Webhook Docker Tag Not Found",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webhookDockerTag !== applicationDockerTag) {
|
||||||
res.status(301).json({
|
res.status(301).json({
|
||||||
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
|
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
|
||||||
});
|
});
|
||||||
@@ -191,7 +241,7 @@ export default async function handler(
|
|||||||
const jobData: DeploymentJob = {
|
const jobData: DeploymentJob = {
|
||||||
applicationId: application.applicationId as string,
|
applicationId: application.applicationId as string,
|
||||||
titleLog: deploymentTitle,
|
titleLog: deploymentTitle,
|
||||||
descriptionLog: `Hash: ${deploymentHash}`,
|
...(deploymentHash && { descriptionLog: `Hash: ${deploymentHash}` }),
|
||||||
type: "deploy",
|
type: "deploy",
|
||||||
applicationType: "application",
|
applicationType: "application",
|
||||||
server: !!application.serverId,
|
server: !!application.serverId,
|
||||||
@@ -222,6 +272,39 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the image name without the tag
|
||||||
|
* Example: "my-image" => "my-image"
|
||||||
|
* Example: "my-image:latest" => "my-image"
|
||||||
|
* Example: "my-image:1.0.0" => "my-image"
|
||||||
|
* Example: "myregistryhost:5000/fedora/httpd:version1.0" => "myregistryhost:5000/fedora/httpd"
|
||||||
|
* @link https://docs.docker.com/reference/cli/docker/image/tag/
|
||||||
|
*/
|
||||||
|
export function extractImageName(dockerImage: string | null): string | null {
|
||||||
|
if (!dockerImage || typeof dockerImage !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case where there's no tag (no colon or colon is part of port number)
|
||||||
|
const lastColonIndex = dockerImage.lastIndexOf(":");
|
||||||
|
if (lastColonIndex === -1) {
|
||||||
|
return dockerImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the part after the last colon looks like a tag (not a port number)
|
||||||
|
// Port numbers are typically 1-5 digits, tags are usually longer or contain letters
|
||||||
|
const afterColon = dockerImage.substring(lastColonIndex + 1);
|
||||||
|
const isPortNumber = /^\d{1,5}$/.test(afterColon);
|
||||||
|
|
||||||
|
// If it's a port number (like registry:5000/image), don't split
|
||||||
|
if (isPortNumber) {
|
||||||
|
return dockerImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, split at the last colon to get image name
|
||||||
|
return dockerImage.substring(0, lastColonIndex);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the last part of the image name, which is the tag
|
* Return the last part of the image name, which is the tag
|
||||||
* Example: "my-image" => null
|
* Example: "my-image" => null
|
||||||
@@ -230,7 +313,7 @@ export default async function handler(
|
|||||||
* Example: "myregistryhost:5000/fedora/httpd:version1.0" => "version1.0"
|
* Example: "myregistryhost:5000/fedora/httpd:version1.0" => "version1.0"
|
||||||
* @link https://docs.docker.com/reference/cli/docker/image/tag/
|
* @link https://docs.docker.com/reference/cli/docker/image/tag/
|
||||||
*/
|
*/
|
||||||
function extractImageTag(dockerImage: string | null) {
|
export function extractImageTag(dockerImage: string | null) {
|
||||||
if (!dockerImage || typeof dockerImage !== "string") {
|
if (!dockerImage || typeof dockerImage !== "string") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -240,12 +323,78 @@ function extractImageTag(dockerImage: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Extract the image name (without tag) from webhook request
|
||||||
* @link https://docs.docker.com/docker-hub/webhooks/#example-webhook-payload
|
* @link https://docs.docker.com/docker-hub/webhooks/#example-webhook-payload
|
||||||
|
* @link https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package
|
||||||
|
*/
|
||||||
|
export const extractImageNameFromRequest = (
|
||||||
|
headers: any,
|
||||||
|
body: any,
|
||||||
|
): string | null => {
|
||||||
|
// GitHub Packages: registry_package events (container registry)
|
||||||
|
const packageVersion = getPackageVersion(headers, body);
|
||||||
|
if (packageVersion?.package_url) {
|
||||||
|
const packageUrl = packageVersion.package_url;
|
||||||
|
// Remove tag if present (everything after the last colon)
|
||||||
|
if (packageUrl.includes(":")) {
|
||||||
|
const lastColonIndex = packageUrl.lastIndexOf(":");
|
||||||
|
// Check if it's a port number (like registry:5000/image)
|
||||||
|
const afterColon = packageUrl.substring(lastColonIndex + 1);
|
||||||
|
const isPortNumber = /^\d{1,5}$/.test(afterColon);
|
||||||
|
if (isPortNumber) {
|
||||||
|
return packageUrl;
|
||||||
|
}
|
||||||
|
return packageUrl.substring(0, lastColonIndex);
|
||||||
|
}
|
||||||
|
return packageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Docker Hub
|
||||||
|
if (headers["user-agent"]?.includes("Go-http-client")) {
|
||||||
|
if (body.repository) {
|
||||||
|
const repoName = body.repository.repo_name;
|
||||||
|
return `${repoName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @link https://docs.docker.com/docker-hub/webhooks/#example-webhook-payload
|
||||||
|
* @link https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package
|
||||||
*/
|
*/
|
||||||
export const extractImageTagFromRequest = (
|
export const extractImageTagFromRequest = (
|
||||||
headers: any,
|
headers: any,
|
||||||
body: any,
|
body: any,
|
||||||
): string | null => {
|
): string | null => {
|
||||||
|
// GitHub Packages: registry_package events (container registry)
|
||||||
|
const packageVersion = getPackageVersion(headers, body);
|
||||||
|
if (packageVersion) {
|
||||||
|
// Try to get tag from container_metadata first (most reliable)
|
||||||
|
// Only use it if it's not empty and not the same as the version (digest)
|
||||||
|
const tagName = packageVersion.container_metadata?.tag?.name?.trim() || "";
|
||||||
|
if (
|
||||||
|
tagName &&
|
||||||
|
tagName !== packageVersion.version &&
|
||||||
|
!tagName.startsWith("sha256:")
|
||||||
|
) {
|
||||||
|
return tagName;
|
||||||
|
}
|
||||||
|
// Fallback: extract tag from package_url (e.g., "ghcr.io/owner/repo:tag")
|
||||||
|
if (packageVersion.package_url) {
|
||||||
|
const packageUrl = packageVersion.package_url;
|
||||||
|
// Handle case where package_url ends with colon (no tag)
|
||||||
|
if (packageUrl.endsWith(":")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tagMatch = packageUrl.match(/:([^:]+)$/);
|
||||||
|
if (tagMatch?.[1]?.trim()) {
|
||||||
|
return tagMatch[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Docker Hub
|
||||||
if (headers["user-agent"]?.includes("Go-http-client")) {
|
if (headers["user-agent"]?.includes("Go-http-client")) {
|
||||||
if (body.push_data && body.repository) {
|
if (body.push_data && body.repository) {
|
||||||
return body.push_data.tag;
|
return body.push_data.tag;
|
||||||
@@ -255,6 +404,18 @@ export const extractImageTagFromRequest = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const extractCommitMessage = (headers: any, body: any) => {
|
export const extractCommitMessage = (headers: any, body: any) => {
|
||||||
|
// GitHub Packages: registry_package events (container tags)
|
||||||
|
const githubEvent = headers["x-github-event"];
|
||||||
|
if (githubEvent === "registry_package") {
|
||||||
|
const packageVersion = getPackageVersion(headers, body);
|
||||||
|
if (packageVersion) {
|
||||||
|
if (packageVersion.package_url) {
|
||||||
|
return `Docker GHCR image pushed: ${packageVersion.package_url}`;
|
||||||
|
}
|
||||||
|
return "Docker GHCR image pushed";
|
||||||
|
}
|
||||||
|
// If package_version is missing, fall through to default behavior
|
||||||
|
}
|
||||||
// GitHub
|
// GitHub
|
||||||
if (headers["x-github-event"]) {
|
if (headers["x-github-event"]) {
|
||||||
return body.head_commit ? body.head_commit.message : "NEW COMMIT";
|
return body.head_commit ? body.head_commit.message : "NEW COMMIT";
|
||||||
@@ -283,7 +444,7 @@ export const extractCommitMessage = (headers: any, body: any) => {
|
|||||||
|
|
||||||
if (headers["user-agent"]?.includes("Go-http-client")) {
|
if (headers["user-agent"]?.includes("Go-http-client")) {
|
||||||
if (body.push_data && body.repository) {
|
if (body.push_data && body.repository) {
|
||||||
return `Docker image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
|
return `DockerHub image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { asc, eq } from "drizzle-orm";
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { db } from "@/server/db";
|
import { db } from "@/server/db";
|
||||||
import { organization, server, users_temp } from "@/server/db/schema";
|
import { organization, server, user } from "@/server/db/schema";
|
||||||
|
|
||||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||||
|
|
||||||
@@ -64,13 +64,13 @@ export default async function handler(
|
|||||||
session.subscription as string,
|
session.subscription as string,
|
||||||
);
|
);
|
||||||
await db
|
await db
|
||||||
.update(users_temp)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
stripeCustomerId: session.customer as string,
|
stripeCustomerId: session.customer as string,
|
||||||
stripeSubscriptionId: session.subscription as string,
|
stripeSubscriptionId: session.subscription as string,
|
||||||
serversQuantity: subscription?.items?.data?.[0]?.quantity ?? 0,
|
serversQuantity: subscription?.items?.data?.[0]?.quantity ?? 0,
|
||||||
})
|
})
|
||||||
.where(eq(users_temp.id, adminId))
|
.where(eq(user.id, adminId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const admin = await findUserById(adminId);
|
const admin = await findUserById(adminId);
|
||||||
@@ -85,14 +85,12 @@ export default async function handler(
|
|||||||
const newSubscription = event.data.object as Stripe.Subscription;
|
const newSubscription = event.data.object as Stripe.Subscription;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users_temp)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
stripeSubscriptionId: newSubscription.id,
|
stripeSubscriptionId: newSubscription.id,
|
||||||
stripeCustomerId: newSubscription.customer as string,
|
stripeCustomerId: newSubscription.customer as string,
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(user.stripeCustomerId, newSubscription.customer as string))
|
||||||
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
|
|
||||||
)
|
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@@ -102,14 +100,12 @@ export default async function handler(
|
|||||||
const newSubscription = event.data.object as Stripe.Subscription;
|
const newSubscription = event.data.object as Stripe.Subscription;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users_temp)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
stripeSubscriptionId: null,
|
stripeSubscriptionId: null,
|
||||||
serversQuantity: 0,
|
serversQuantity: 0,
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(user.stripeCustomerId, newSubscription.customer as string));
|
||||||
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
|
|
||||||
);
|
|
||||||
|
|
||||||
const admin = await findUserByStripeCustomerId(
|
const admin = await findUserByStripeCustomerId(
|
||||||
newSubscription.customer as string,
|
newSubscription.customer as string,
|
||||||
@@ -135,24 +131,20 @@ export default async function handler(
|
|||||||
|
|
||||||
if (newSubscription.status === "active") {
|
if (newSubscription.status === "active") {
|
||||||
await db
|
await db
|
||||||
.update(users_temp)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
serversQuantity: newSubscription?.items?.data?.[0]?.quantity ?? 0,
|
serversQuantity: newSubscription?.items?.data?.[0]?.quantity ?? 0,
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(user.stripeCustomerId, newSubscription.customer as string));
|
||||||
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
|
|
||||||
);
|
|
||||||
|
|
||||||
const newServersQuantity = admin.serversQuantity;
|
const newServersQuantity = admin.serversQuantity;
|
||||||
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
||||||
} else {
|
} else {
|
||||||
await disableServers(admin.id);
|
await disableServers(admin.id);
|
||||||
await db
|
await db
|
||||||
.update(users_temp)
|
.update(user)
|
||||||
.set({ serversQuantity: 0 })
|
.set({ serversQuantity: 0 })
|
||||||
.where(
|
.where(eq(user.stripeCustomerId, newSubscription.customer as string));
|
||||||
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
@@ -172,11 +164,11 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users_temp)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
serversQuantity: suscription?.items?.data?.[0]?.quantity ?? 0,
|
serversQuantity: suscription?.items?.data?.[0]?.quantity ?? 0,
|
||||||
})
|
})
|
||||||
.where(eq(users_temp.stripeCustomerId, suscription.customer as string));
|
.where(eq(user.stripeCustomerId, suscription.customer as string));
|
||||||
|
|
||||||
const admin = await findUserByStripeCustomerId(
|
const admin = await findUserByStripeCustomerId(
|
||||||
suscription.customer as string,
|
suscription.customer as string,
|
||||||
@@ -205,13 +197,11 @@ export default async function handler(
|
|||||||
return res.status(400).send("Webhook Error: Admin not found");
|
return res.status(400).send("Webhook Error: Admin not found");
|
||||||
}
|
}
|
||||||
await db
|
await db
|
||||||
.update(users_temp)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
serversQuantity: 0,
|
serversQuantity: 0,
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(user.stripeCustomerId, newInvoice.customer as string));
|
||||||
eq(users_temp.stripeCustomerId, newInvoice.customer as string),
|
|
||||||
);
|
|
||||||
|
|
||||||
await disableServers(admin.id);
|
await disableServers(admin.id);
|
||||||
}
|
}
|
||||||
@@ -229,13 +219,13 @@ export default async function handler(
|
|||||||
|
|
||||||
await disableServers(admin.id);
|
await disableServers(admin.id);
|
||||||
await db
|
await db
|
||||||
.update(users_temp)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
stripeCustomerId: null,
|
stripeCustomerId: null,
|
||||||
stripeSubscriptionId: null,
|
stripeSubscriptionId: null,
|
||||||
serversQuantity: 0,
|
serversQuantity: 0,
|
||||||
})
|
})
|
||||||
.where(eq(users_temp.stripeCustomerId, customer.id));
|
.where(eq(user.stripeCustomerId, customer.id));
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -262,10 +252,10 @@ const disableServers = async (userId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const findUserByStripeCustomerId = async (stripeCustomerId: string) => {
|
const findUserByStripeCustomerId = async (stripeCustomerId: string) => {
|
||||||
const user = db.query.users_temp.findFirst({
|
const userResult = await db.query.user.findFirst({
|
||||||
where: eq(users_temp.stripeCustomerId, stripeCustomerId),
|
where: eq(user.stripeCustomerId, stripeCustomerId),
|
||||||
});
|
});
|
||||||
return user;
|
return userResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
const activateServer = async (serverId: string) => {
|
const activateServer = async (serverId: string) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { findProjectById } from "@dokploy/server";
|
import type { findEnvironmentById } from "@dokploy/server";
|
||||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
import {
|
import {
|
||||||
@@ -102,6 +102,7 @@ import { api } from "@/utils/api";
|
|||||||
export type Services = {
|
export type Services = {
|
||||||
appName: string;
|
appName: string;
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
|
serverName?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
type:
|
type:
|
||||||
| "mariadb"
|
| "mariadb"
|
||||||
@@ -115,10 +116,10 @@ export type Services = {
|
|||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
status?: "idle" | "running" | "done" | "error";
|
status?: "idle" | "running" | "done" | "error";
|
||||||
|
lastDeployDate?: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Project = Awaited<ReturnType<typeof findProjectById>>;
|
type Environment = Awaited<ReturnType<typeof findEnvironmentById>>;
|
||||||
type Environment = Project["environments"][0];
|
|
||||||
|
|
||||||
export const extractServicesFromEnvironment = (
|
export const extractServicesFromEnvironment = (
|
||||||
environment: Environment | undefined,
|
environment: Environment | undefined,
|
||||||
@@ -128,16 +129,35 @@ export const extractServicesFromEnvironment = (
|
|||||||
const allServices: Services[] = [];
|
const allServices: Services[] = [];
|
||||||
|
|
||||||
const applications: Services[] =
|
const applications: Services[] =
|
||||||
environment.applications?.map((item) => ({
|
environment.applications?.map((item) => {
|
||||||
appName: item.appName,
|
// Get the most recent deployment date
|
||||||
name: item.name,
|
let lastDeployDate: Date | null = null;
|
||||||
type: "application",
|
const deployments = (item as any).deployments;
|
||||||
id: item.applicationId,
|
if (deployments && deployments.length > 0) {
|
||||||
createdAt: item.createdAt,
|
for (const deployment of deployments) {
|
||||||
status: item.applicationStatus,
|
const deployDate = new Date(
|
||||||
description: item.description,
|
deployment.finishedAt ||
|
||||||
serverId: item.serverId,
|
deployment.startedAt ||
|
||||||
})) || [];
|
deployment.createdAt,
|
||||||
|
);
|
||||||
|
if (!lastDeployDate || deployDate > lastDeployDate) {
|
||||||
|
lastDeployDate = deployDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
appName: item.appName,
|
||||||
|
name: item.name,
|
||||||
|
type: "application",
|
||||||
|
id: item.applicationId,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
status: item.applicationStatus,
|
||||||
|
description: item.description,
|
||||||
|
serverId: item.serverId,
|
||||||
|
serverName: item?.server?.name || null,
|
||||||
|
lastDeployDate,
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
const mariadb: Services[] =
|
const mariadb: Services[] =
|
||||||
environment.mariadb?.map((item) => ({
|
environment.mariadb?.map((item) => ({
|
||||||
@@ -149,6 +169,7 @@ export const extractServicesFromEnvironment = (
|
|||||||
status: item.applicationStatus,
|
status: item.applicationStatus,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
serverId: item.serverId,
|
serverId: item.serverId,
|
||||||
|
serverName: item?.server?.name || null,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const postgres: Services[] =
|
const postgres: Services[] =
|
||||||
@@ -161,6 +182,7 @@ export const extractServicesFromEnvironment = (
|
|||||||
status: item.applicationStatus,
|
status: item.applicationStatus,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
serverId: item.serverId,
|
serverId: item.serverId,
|
||||||
|
serverName: item?.server?.name || null,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const mongo: Services[] =
|
const mongo: Services[] =
|
||||||
@@ -173,6 +195,7 @@ export const extractServicesFromEnvironment = (
|
|||||||
status: item.applicationStatus,
|
status: item.applicationStatus,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
serverId: item.serverId,
|
serverId: item.serverId,
|
||||||
|
serverName: item?.server?.name || null,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const redis: Services[] =
|
const redis: Services[] =
|
||||||
@@ -185,6 +208,7 @@ export const extractServicesFromEnvironment = (
|
|||||||
status: item.applicationStatus,
|
status: item.applicationStatus,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
serverId: item.serverId,
|
serverId: item.serverId,
|
||||||
|
serverName: item?.server?.name || null,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const mysql: Services[] =
|
const mysql: Services[] =
|
||||||
@@ -197,19 +221,39 @@ export const extractServicesFromEnvironment = (
|
|||||||
status: item.applicationStatus,
|
status: item.applicationStatus,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
serverId: item.serverId,
|
serverId: item.serverId,
|
||||||
|
serverName: item?.server?.name || null,
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|
||||||
const compose: Services[] =
|
const compose: Services[] =
|
||||||
environment.compose?.map((item) => ({
|
environment.compose?.map((item) => {
|
||||||
appName: item.appName,
|
// Get the most recent deployment date
|
||||||
name: item.name,
|
let lastDeployDate: Date | null = null;
|
||||||
type: "compose",
|
const deployments = (item as any).deployments;
|
||||||
id: item.composeId,
|
if (deployments && deployments.length > 0) {
|
||||||
createdAt: item.createdAt,
|
for (const deployment of deployments) {
|
||||||
status: item.composeStatus,
|
const deployDate = new Date(
|
||||||
description: item.description,
|
deployment.finishedAt ||
|
||||||
serverId: item.serverId,
|
deployment.startedAt ||
|
||||||
})) || [];
|
deployment.createdAt,
|
||||||
|
);
|
||||||
|
if (!lastDeployDate || deployDate > lastDeployDate) {
|
||||||
|
lastDeployDate = deployDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
appName: item.appName,
|
||||||
|
name: item.name,
|
||||||
|
type: "compose",
|
||||||
|
id: item.composeId,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
status: item.composeStatus,
|
||||||
|
description: item.description,
|
||||||
|
serverId: item.serverId,
|
||||||
|
serverName: item?.server?.name || null,
|
||||||
|
lastDeployDate,
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
allServices.push(
|
allServices.push(
|
||||||
...applications,
|
...applications,
|
||||||
@@ -237,9 +281,9 @@ const EnvironmentPage = (
|
|||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const [sortBy, setSortBy] = useState<string>(() => {
|
const [sortBy, setSortBy] = useState<string>(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
return localStorage.getItem("servicesSort") || "createdAt-desc";
|
return localStorage.getItem("servicesSort") || "lastDeploy-desc";
|
||||||
}
|
}
|
||||||
return "createdAt-desc";
|
return "lastDeploy-desc";
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -261,10 +305,45 @@ const EnvironmentPage = (
|
|||||||
comparison =
|
comparison =
|
||||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||||
break;
|
break;
|
||||||
|
case "lastDeploy": {
|
||||||
|
const aLastDeploy = a.lastDeployDate;
|
||||||
|
const bLastDeploy = b.lastDeployDate;
|
||||||
|
|
||||||
|
if (direction === "desc") {
|
||||||
|
// For "desc" (newest first): services with deployments first, then those without
|
||||||
|
if (!aLastDeploy && !bLastDeploy) {
|
||||||
|
comparison = 0;
|
||||||
|
} else if (!aLastDeploy) {
|
||||||
|
comparison = 1; // a (no deploy) goes after b (has deploy)
|
||||||
|
} else if (!bLastDeploy) {
|
||||||
|
comparison = -1; // a (has deploy) goes before b (no deploy)
|
||||||
|
} else {
|
||||||
|
// Both have deployments: newest first (negative if a is newer)
|
||||||
|
comparison = bLastDeploy.getTime() - aLastDeploy.getTime();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For "asc" (oldest first): services with deployments first, then those without
|
||||||
|
if (!aLastDeploy && !bLastDeploy) {
|
||||||
|
comparison = 0;
|
||||||
|
} else if (!aLastDeploy) {
|
||||||
|
comparison = 1; // a (no deploy) goes after b (has deploy)
|
||||||
|
} else if (!bLastDeploy) {
|
||||||
|
comparison = -1; // a (has deploy) goes before b (no deploy)
|
||||||
|
} else {
|
||||||
|
// Both have deployments: oldest first
|
||||||
|
comparison = aLastDeploy.getTime() - bLastDeploy.getTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
comparison = 0;
|
comparison = 0;
|
||||||
}
|
}
|
||||||
return direction === "asc" ? comparison : -comparison;
|
// For other fields, apply direction normally
|
||||||
|
if (field !== "lastDeploy") {
|
||||||
|
return direction === "asc" ? comparison : -comparison;
|
||||||
|
}
|
||||||
|
return comparison;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -320,6 +399,7 @@ const EnvironmentPage = (
|
|||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||||
const [deleteVolumes, setDeleteVolumes] = useState(false);
|
const [deleteVolumes, setDeleteVolumes] = useState(false);
|
||||||
|
const [selectedServerId, setSelectedServerId] = useState<string>("all");
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
if (selectedServices.length === filteredServices.length) {
|
if (selectedServices.length === filteredServices.length) {
|
||||||
@@ -709,6 +789,27 @@ const EnvironmentPage = (
|
|||||||
setIsBulkActionLoading(false);
|
setIsBulkActionLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get unique servers from services
|
||||||
|
const availableServers = useMemo(() => {
|
||||||
|
if (!applications) return [];
|
||||||
|
const servers = new Map<string, { serverId: string; serverName: string }>();
|
||||||
|
applications.forEach((service) => {
|
||||||
|
if (service.serverId && service.serverName) {
|
||||||
|
servers.set(service.serverId, {
|
||||||
|
serverId: service.serverId,
|
||||||
|
serverName: service.serverName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(servers.values());
|
||||||
|
}, [applications]);
|
||||||
|
|
||||||
|
// Check if there are services without a server (Dokploy server)
|
||||||
|
const hasServicesWithoutServer = useMemo(() => {
|
||||||
|
if (!applications) return false;
|
||||||
|
return applications.some((service) => !service.serverId);
|
||||||
|
}, [applications]);
|
||||||
|
|
||||||
const filteredServices = useMemo(() => {
|
const filteredServices = useMemo(() => {
|
||||||
if (!applications) return [];
|
if (!applications) return [];
|
||||||
const filtered = applications.filter(
|
const filtered = applications.filter(
|
||||||
@@ -717,10 +818,14 @@ const EnvironmentPage = (
|
|||||||
service.description
|
service.description
|
||||||
?.toLowerCase()
|
?.toLowerCase()
|
||||||
.includes(searchQuery.toLowerCase())) &&
|
.includes(searchQuery.toLowerCase())) &&
|
||||||
(selectedTypes.length === 0 || selectedTypes.includes(service.type)),
|
(selectedTypes.length === 0 || selectedTypes.includes(service.type)) &&
|
||||||
|
(selectedServerId === "" ||
|
||||||
|
selectedServerId === "all" ||
|
||||||
|
(selectedServerId === "dokploy-server" && !service.serverId) ||
|
||||||
|
service.serverId === selectedServerId),
|
||||||
);
|
);
|
||||||
return sortServices(filtered);
|
return sortServices(filtered);
|
||||||
}, [applications, searchQuery, selectedTypes, sortBy]);
|
}, [applications, searchQuery, selectedTypes, selectedServerId, sortBy]);
|
||||||
|
|
||||||
const selectedServicesWithRunningStatus = useMemo(() => {
|
const selectedServicesWithRunningStatus = useMemo(() => {
|
||||||
return filteredServices.filter(
|
return filteredServices.filter(
|
||||||
@@ -1217,6 +1322,9 @@ const EnvironmentPage = (
|
|||||||
<SelectValue placeholder="Sort by..." />
|
<SelectValue placeholder="Sort by..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem value="lastDeploy-desc">
|
||||||
|
Recently deployed
|
||||||
|
</SelectItem>
|
||||||
<SelectItem value="createdAt-desc">
|
<SelectItem value="createdAt-desc">
|
||||||
Newest first
|
Newest first
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -1291,6 +1399,39 @@ const EnvironmentPage = (
|
|||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
{(availableServers.length > 0 ||
|
||||||
|
hasServicesWithoutServer) && (
|
||||||
|
<Select
|
||||||
|
value={selectedServerId || "all"}
|
||||||
|
onValueChange={setSelectedServerId}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="lg:w-[200px]">
|
||||||
|
<SelectValue placeholder="Filter by server..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All servers</SelectItem>
|
||||||
|
{hasServicesWithoutServer && (
|
||||||
|
<SelectItem value="dokploy-server">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ServerIcon className="size-4" />
|
||||||
|
<span>Dokploy server</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
{availableServers.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ServerIcon className="size-4" />
|
||||||
|
<span>{server.serverName}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1396,7 +1537,15 @@ const EnvironmentPage = (
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="mt-auto">
|
<CardFooter className="mt-auto">
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-1 text-sm w-full">
|
||||||
|
{service.serverName && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||||
|
<ServerIcon className="size-3" />
|
||||||
|
<span className="truncate">
|
||||||
|
{service.serverName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<DateTooltip date={service.createdAt}>
|
<DateTooltip date={service.createdAt}>
|
||||||
Created
|
Created
|
||||||
</DateTooltip>
|
</DateTooltip>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { AddCommand } from "@/components/dashboard/application/advanced/general/
|
|||||||
import { ShowPorts } from "@/components/dashboard/application/advanced/ports/show-port";
|
import { ShowPorts } from "@/components/dashboard/application/advanced/ports/show-port";
|
||||||
import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects";
|
import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects";
|
||||||
import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security";
|
import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security";
|
||||||
|
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
|
||||||
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
|
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
|
||||||
import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config";
|
import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config";
|
||||||
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
|
||||||
@@ -353,7 +354,7 @@ const Service = (
|
|||||||
id={applicationId}
|
id={applicationId}
|
||||||
type="application"
|
type="application"
|
||||||
/>
|
/>
|
||||||
|
<ShowBuildServer applicationId={applicationId} />
|
||||||
<ShowResources id={applicationId} type="application" />
|
<ShowResources id={applicationId} type="application" />
|
||||||
<ShowVolumes id={applicationId} type="application" />
|
<ShowVolumes id={applicationId} type="application" />
|
||||||
<ShowRedirects applicationId={applicationId} />
|
<ShowRedirects applicationId={applicationId} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { findAdmin } from "@dokploy/server";
|
import { findAdmin } from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { users_temp } from "@dokploy/server/db/schema";
|
import { user } from "@dokploy/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -8,11 +8,11 @@ import { eq } from "drizzle-orm";
|
|||||||
const result = await findAdmin();
|
const result = await findAdmin();
|
||||||
|
|
||||||
const update = await db
|
const update = await db
|
||||||
.update(users_temp)
|
.update(user)
|
||||||
.set({
|
.set({
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
})
|
})
|
||||||
.where(eq(users_temp.id, result.userId));
|
.where(eq(user.id, result.userId));
|
||||||
|
|
||||||
if (update) {
|
if (update) {
|
||||||
console.log("2FA reset successful");
|
console.log("2FA reset successful");
|
||||||
|
|||||||
132
apps/dokploy/scripts/generate-openapi.ts
Normal file
132
apps/dokploy/scripts/generate-openapi.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to generate OpenAPI specification locally
|
||||||
|
* This runs in CI/CD to generate the openapi.json file
|
||||||
|
* which can then be consumed by the documentation website
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
|
||||||
|
import { appRouter } from "../server/api/root";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
async function generateOpenAPI() {
|
||||||
|
try {
|
||||||
|
console.log("🔄 Generating OpenAPI specification...");
|
||||||
|
|
||||||
|
const openApiDocument = generateOpenApiDocument(appRouter, {
|
||||||
|
title: "Dokploy API",
|
||||||
|
version: "1.0.0",
|
||||||
|
baseUrl: "https://your-dokploy-instance.com/api",
|
||||||
|
docsUrl: "https://docs.dokploy.com/api",
|
||||||
|
tags: [
|
||||||
|
"admin",
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"registry",
|
||||||
|
"cluster",
|
||||||
|
"user",
|
||||||
|
"domain",
|
||||||
|
"destination",
|
||||||
|
"backup",
|
||||||
|
"deployment",
|
||||||
|
"mounts",
|
||||||
|
"certificates",
|
||||||
|
"settings",
|
||||||
|
"security",
|
||||||
|
"redirects",
|
||||||
|
"port",
|
||||||
|
"project",
|
||||||
|
"application",
|
||||||
|
"mysql",
|
||||||
|
"postgres",
|
||||||
|
"redis",
|
||||||
|
"mongo",
|
||||||
|
"mariadb",
|
||||||
|
"sshRouter",
|
||||||
|
"gitProvider",
|
||||||
|
"bitbucket",
|
||||||
|
"github",
|
||||||
|
"gitlab",
|
||||||
|
"gitea",
|
||||||
|
"server",
|
||||||
|
"swarm",
|
||||||
|
"ai",
|
||||||
|
"organization",
|
||||||
|
"schedule",
|
||||||
|
"rollback",
|
||||||
|
"volumeBackups",
|
||||||
|
"environment",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enhance metadata
|
||||||
|
openApiDocument.info = {
|
||||||
|
title: "Dokploy API",
|
||||||
|
description:
|
||||||
|
"Complete API documentation for Dokploy - Deploy applications, manage databases, and orchestrate your infrastructure. This API allows you to programmatically manage all aspects of your Dokploy instance.",
|
||||||
|
version: "1.0.0",
|
||||||
|
contact: {
|
||||||
|
name: "Dokploy Team",
|
||||||
|
url: "https://dokploy.com",
|
||||||
|
},
|
||||||
|
license: {
|
||||||
|
name: "Apache 2.0",
|
||||||
|
url: "https://github.com/dokploy/dokploy/blob/canary/LICENSE",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add security schemes
|
||||||
|
openApiDocument.components = {
|
||||||
|
...openApiDocument.components,
|
||||||
|
securitySchemes: {
|
||||||
|
apiKey: {
|
||||||
|
type: "apiKey",
|
||||||
|
in: "header",
|
||||||
|
name: "x-api-key",
|
||||||
|
description:
|
||||||
|
"API key authentication. Generate an API key from your Dokploy dashboard under Settings > API Keys.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply global security
|
||||||
|
openApiDocument.security = [
|
||||||
|
{
|
||||||
|
apiKey: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add external docs
|
||||||
|
openApiDocument.externalDocs = {
|
||||||
|
description: "Full documentation",
|
||||||
|
url: "https://docs.dokploy.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write to root of repo
|
||||||
|
const outputPath = resolve(__dirname, "../../../openapi.json");
|
||||||
|
writeFileSync(
|
||||||
|
outputPath,
|
||||||
|
JSON.stringify(openApiDocument, null, 2),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("✅ OpenAPI specification generated successfully!");
|
||||||
|
console.log(`📄 Output: ${outputPath}`);
|
||||||
|
console.log(
|
||||||
|
`📊 Endpoints: ${Object.keys(openApiDocument.paths || {}).length}`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error generating OpenAPI specification:", error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
generateOpenAPI();
|
||||||
@@ -58,7 +58,11 @@ import {
|
|||||||
applications,
|
applications,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||||
import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup";
|
import {
|
||||||
|
cleanQueuesByApplication,
|
||||||
|
killDockerBuild,
|
||||||
|
myQueue,
|
||||||
|
} from "@/server/queues/queueSetup";
|
||||||
import { cancelDeployment, deploy } from "@/server/utils/deploy";
|
import { cancelDeployment, deploy } from "@/server/utils/deploy";
|
||||||
import { uploadFileSchema } from "@/utils/schema";
|
import { uploadFileSchema } from "@/utils/schema";
|
||||||
|
|
||||||
@@ -725,7 +729,21 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
await cleanQueuesByApplication(input.applicationId);
|
await cleanQueuesByApplication(input.applicationId);
|
||||||
}),
|
}),
|
||||||
|
killBuild: protectedProcedure
|
||||||
|
.input(apiFindOneApplication)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const application = await findApplicationById(input.applicationId);
|
||||||
|
if (
|
||||||
|
application.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to kill this build",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await killDockerBuild("application", application.serverId);
|
||||||
|
}),
|
||||||
readTraefikConfig: protectedProcedure
|
readTraefikConfig: protectedProcedure
|
||||||
.input(apiFindOneApplication)
|
.input(apiFindOneApplication)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import {
|
|||||||
addNewService,
|
addNewService,
|
||||||
checkServiceAccess,
|
checkServiceAccess,
|
||||||
cloneCompose,
|
cloneCompose,
|
||||||
cloneComposeRemote,
|
|
||||||
createCommand,
|
createCommand,
|
||||||
createCompose,
|
createCompose,
|
||||||
createComposeByTemplate,
|
createComposeByTemplate,
|
||||||
createDomain,
|
createDomain,
|
||||||
createMount,
|
createMount,
|
||||||
deleteMount,
|
deleteMount,
|
||||||
|
execAsync,
|
||||||
|
execAsyncRemote,
|
||||||
findComposeById,
|
findComposeById,
|
||||||
findDomainsByComposeId,
|
findDomainsByComposeId,
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
@@ -58,7 +59,11 @@ import {
|
|||||||
compose as composeTable,
|
compose as composeTable,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||||
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
|
import {
|
||||||
|
cleanQueuesByCompose,
|
||||||
|
killDockerBuild,
|
||||||
|
myQueue,
|
||||||
|
} from "@/server/queues/queueSetup";
|
||||||
import { cancelDeployment, deploy } from "@/server/utils/deploy";
|
import { cancelDeployment, deploy } from "@/server/utils/deploy";
|
||||||
import { generatePassword } from "@/templates/utils";
|
import { generatePassword } from "@/templates/utils";
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
@@ -245,6 +250,22 @@ export const composeRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await cleanQueuesByCompose(input.composeId);
|
await cleanQueuesByCompose(input.composeId);
|
||||||
|
return { success: true, message: "Queues cleaned successfully" };
|
||||||
|
}),
|
||||||
|
killBuild: protectedProcedure
|
||||||
|
.input(apiFindCompose)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
if (
|
||||||
|
compose.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to kill this build",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await killDockerBuild("compose", compose.serverId);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
loadServices: protectedProcedure
|
loadServices: protectedProcedure
|
||||||
@@ -301,10 +322,12 @@ export const composeRouter = createTRPCRouter({
|
|||||||
message: "You are not authorized to fetch this compose",
|
message: "You are not authorized to fetch this compose",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const command = await cloneCompose(compose);
|
||||||
if (compose.serverId) {
|
if (compose.serverId) {
|
||||||
await cloneComposeRemote(compose);
|
await execAsyncRemote(compose.serverId, command);
|
||||||
} else {
|
} else {
|
||||||
await cloneCompose(compose);
|
await execAsync(command);
|
||||||
}
|
}
|
||||||
return compose.sourceType;
|
return compose.sourceType;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -405,6 +428,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
removeOnFail: true,
|
removeOnFail: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
return { success: true, message: "Deployment queued" };
|
||||||
}),
|
}),
|
||||||
redeploy: protectedProcedure
|
redeploy: protectedProcedure
|
||||||
.input(apiRedeployCompose)
|
.input(apiRedeployCompose)
|
||||||
@@ -440,6 +464,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
removeOnFail: true,
|
removeOnFail: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
return { success: true, message: "Redeployment queued" };
|
||||||
}),
|
}),
|
||||||
stop: protectedProcedure
|
stop: protectedProcedure
|
||||||
.input(apiFindCompose)
|
.input(apiFindCompose)
|
||||||
|
|||||||
@@ -47,15 +47,19 @@ export const destinationRouter = createTRPCRouter({
|
|||||||
input;
|
input;
|
||||||
try {
|
try {
|
||||||
const rcloneFlags = [
|
const rcloneFlags = [
|
||||||
`--s3-access-key-id=${accessKey}`,
|
`--s3-access-key-id="${accessKey}"`,
|
||||||
`--s3-secret-access-key=${secretAccessKey}`,
|
`--s3-secret-access-key="${secretAccessKey}"`,
|
||||||
`--s3-region=${region}`,
|
`--s3-region="${region}"`,
|
||||||
`--s3-endpoint=${endpoint}`,
|
`--s3-endpoint="${endpoint}"`,
|
||||||
"--s3-no-check-bucket",
|
"--s3-no-check-bucket",
|
||||||
"--s3-force-path-style",
|
"--s3-force-path-style",
|
||||||
|
"--retries 1",
|
||||||
|
"--low-level-retries 1",
|
||||||
|
"--timeout 10s",
|
||||||
|
"--contimeout 5s",
|
||||||
];
|
];
|
||||||
if (provider) {
|
if (provider) {
|
||||||
rcloneFlags.unshift(`--s3-provider=${provider}`);
|
rcloneFlags.unshift(`--s3-provider="${provider}"`);
|
||||||
}
|
}
|
||||||
const rcloneDestination = `:s3:${bucket}`;
|
const rcloneDestination = `:s3:${bucket}`;
|
||||||
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
createDiscordNotification,
|
createDiscordNotification,
|
||||||
createEmailNotification,
|
createEmailNotification,
|
||||||
createLarkNotification,
|
|
||||||
createGotifyNotification,
|
createGotifyNotification,
|
||||||
|
createLarkNotification,
|
||||||
createNtfyNotification,
|
createNtfyNotification,
|
||||||
createSlackNotification,
|
createSlackNotification,
|
||||||
createTelegramNotification,
|
createTelegramNotification,
|
||||||
@@ -11,16 +11,16 @@ import {
|
|||||||
removeNotificationById,
|
removeNotificationById,
|
||||||
sendDiscordNotification,
|
sendDiscordNotification,
|
||||||
sendEmailNotification,
|
sendEmailNotification,
|
||||||
sendLarkNotification,
|
|
||||||
sendGotifyNotification,
|
sendGotifyNotification,
|
||||||
|
sendLarkNotification,
|
||||||
sendNtfyNotification,
|
sendNtfyNotification,
|
||||||
sendServerThresholdNotifications,
|
sendServerThresholdNotifications,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
updateDiscordNotification,
|
updateDiscordNotification,
|
||||||
updateEmailNotification,
|
updateEmailNotification,
|
||||||
updateLarkNotification,
|
|
||||||
updateGotifyNotification,
|
updateGotifyNotification,
|
||||||
|
updateLarkNotification,
|
||||||
updateNtfyNotification,
|
updateNtfyNotification,
|
||||||
updateSlackNotification,
|
updateSlackNotification,
|
||||||
updateTelegramNotification,
|
updateTelegramNotification,
|
||||||
@@ -38,29 +38,29 @@ import { db } from "@/server/db";
|
|||||||
import {
|
import {
|
||||||
apiCreateDiscord,
|
apiCreateDiscord,
|
||||||
apiCreateEmail,
|
apiCreateEmail,
|
||||||
apiCreateLark,
|
|
||||||
apiCreateGotify,
|
apiCreateGotify,
|
||||||
|
apiCreateLark,
|
||||||
apiCreateNtfy,
|
apiCreateNtfy,
|
||||||
apiCreateSlack,
|
apiCreateSlack,
|
||||||
apiCreateTelegram,
|
apiCreateTelegram,
|
||||||
apiFindOneNotification,
|
apiFindOneNotification,
|
||||||
apiTestDiscordConnection,
|
apiTestDiscordConnection,
|
||||||
apiTestEmailConnection,
|
apiTestEmailConnection,
|
||||||
apiTestLarkConnection,
|
|
||||||
apiTestGotifyConnection,
|
apiTestGotifyConnection,
|
||||||
|
apiTestLarkConnection,
|
||||||
apiTestNtfyConnection,
|
apiTestNtfyConnection,
|
||||||
apiTestSlackConnection,
|
apiTestSlackConnection,
|
||||||
apiTestTelegramConnection,
|
apiTestTelegramConnection,
|
||||||
apiUpdateDiscord,
|
apiUpdateDiscord,
|
||||||
apiUpdateEmail,
|
apiUpdateEmail,
|
||||||
apiUpdateLark,
|
|
||||||
apiUpdateGotify,
|
apiUpdateGotify,
|
||||||
|
apiUpdateLark,
|
||||||
apiUpdateNtfy,
|
apiUpdateNtfy,
|
||||||
apiUpdateSlack,
|
apiUpdateSlack,
|
||||||
apiUpdateTelegram,
|
apiUpdateTelegram,
|
||||||
notifications,
|
notifications,
|
||||||
server,
|
server,
|
||||||
users_temp,
|
user,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
|
||||||
export const notificationRouter = createTRPCRouter({
|
export const notificationRouter = createTRPCRouter({
|
||||||
@@ -111,7 +111,7 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Error testing the notification",
|
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
cause: error,
|
cause: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -228,7 +228,7 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Error testing the notification",
|
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
cause: error,
|
cause: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -285,7 +285,7 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Error testing the notification",
|
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
cause: error,
|
cause: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -359,9 +359,9 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
if (input.ServerType === "Dokploy") {
|
if (input.ServerType === "Dokploy") {
|
||||||
const result = await db
|
const result = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users_temp)
|
.from(user)
|
||||||
.where(
|
.where(
|
||||||
sql`${users_temp.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
|
sql`${user.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result?.[0]?.id) {
|
if (!result?.[0]?.id) {
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this is the user's first organization
|
||||||
|
const existingMemberships = await db.query.member.findMany({
|
||||||
|
where: eq(member.userId, ctx.user.id),
|
||||||
|
});
|
||||||
|
|
||||||
await db.insert(member).values({
|
await db.insert(member).values({
|
||||||
organizationId: result.id,
|
organizationId: result.id,
|
||||||
role: "owner",
|
role: "owner",
|
||||||
@@ -63,6 +68,11 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
with: {
|
||||||
|
members: {
|
||||||
|
where: eq(member.userId, ctx.user.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return memberResult;
|
return memberResult;
|
||||||
}),
|
}),
|
||||||
@@ -184,4 +194,45 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
.delete(invitation)
|
.delete(invitation)
|
||||||
.where(eq(invitation.id, input.invitationId));
|
.where(eq(invitation.id, input.invitationId));
|
||||||
}),
|
}),
|
||||||
|
setDefault: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
organizationId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Verify user is a member of this organization
|
||||||
|
const userMember = await db.query.member.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(member.organizationId, input.organizationId),
|
||||||
|
eq(member.userId, ctx.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userMember) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You are not a member of this organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, unset all defaults for this user
|
||||||
|
await db
|
||||||
|
.update(member)
|
||||||
|
.set({ isDefault: false })
|
||||||
|
.where(eq(member.userId, ctx.user.id));
|
||||||
|
|
||||||
|
// Then set this organization as default
|
||||||
|
await db
|
||||||
|
.update(member)
|
||||||
|
.set({ isDefault: true })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(member.organizationId, input.organizationId),
|
||||||
|
eq(member.userId, ctx.user.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findPostgresById,
|
findPostgresById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
|
getMountPath,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
removePostgresById,
|
removePostgresById,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
postgres as postgresTable,
|
postgres as postgresTable,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import { cancelJobs } from "@/server/utils/backup";
|
import { cancelJobs } from "@/server/utils/backup";
|
||||||
|
|
||||||
export const postgresRouter = createTRPCRouter({
|
export const postgresRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(apiCreatePostgres)
|
.input(apiCreatePostgres)
|
||||||
@@ -79,11 +81,13 @@ export const postgresRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mountPath = getMountPath(input.dockerImage);
|
||||||
|
|
||||||
await createMount({
|
await createMount({
|
||||||
serviceId: newPostgres.postgresId,
|
serviceId: newPostgres.postgresId,
|
||||||
serviceType: "postgres",
|
serviceType: "postgres",
|
||||||
volumeName: `${newPostgres.appName}-data`,
|
volumeName: `${newPostgres.appName}-data`,
|
||||||
mountPath: "/var/lib/postgresql/data",
|
mountPath: mountPath,
|
||||||
type: "volume",
|
type: "volume",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,12 +286,16 @@ export const postgresRouter = createTRPCRouter({
|
|||||||
const backups = await findBackupsByDbId(input.postgresId, "postgres");
|
const backups = await findBackupsByDbId(input.postgresId, "postgres");
|
||||||
|
|
||||||
const cleanupOperations = [
|
const cleanupOperations = [
|
||||||
removeService(postgres.appName, postgres.serverId),
|
async () => await removeService(postgres?.appName, postgres.serverId),
|
||||||
cancelJobs(backups),
|
async () => await cancelJobs(backups),
|
||||||
removePostgresById(input.postgresId),
|
async () => await removePostgresById(input.postgresId),
|
||||||
];
|
];
|
||||||
|
|
||||||
await Promise.allSettled(cleanupOperations);
|
for (const operation of cleanupOperations) {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
return postgres;
|
return postgres;
|
||||||
}),
|
}),
|
||||||
@@ -363,6 +371,7 @@ export const postgresRouter = createTRPCRouter({
|
|||||||
message: "You are not authorized to update this Postgres",
|
message: "You are not authorized to update this Postgres",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = await updatePostgresById(postgresId, {
|
const service = await updatePostgresById(postgresId, {
|
||||||
...rest,
|
...rest,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,8 +81,10 @@ export const serverRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
getDefaultCommand: protectedProcedure
|
getDefaultCommand: protectedProcedure
|
||||||
.input(apiFindOneServer)
|
.input(apiFindOneServer)
|
||||||
.query(async () => {
|
.query(async ({ input }) => {
|
||||||
return defaultCommand();
|
const server = await findServerById(input.serverId);
|
||||||
|
const isBuildServer = server.serverType === "build";
|
||||||
|
return defaultCommand(isBuildServer);
|
||||||
}),
|
}),
|
||||||
all: protectedProcedure.query(async ({ ctx }) => {
|
all: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const result = await db
|
const result = await db
|
||||||
@@ -124,10 +126,30 @@ export const serverRouter = createTRPCRouter({
|
|||||||
isNotNull(server.sshKeyId),
|
isNotNull(server.sshKeyId),
|
||||||
eq(server.organizationId, ctx.session.activeOrganizationId),
|
eq(server.organizationId, ctx.session.activeOrganizationId),
|
||||||
eq(server.serverStatus, "active"),
|
eq(server.serverStatus, "active"),
|
||||||
|
eq(server.serverType, "deploy"),
|
||||||
)
|
)
|
||||||
: and(
|
: and(
|
||||||
isNotNull(server.sshKeyId),
|
isNotNull(server.sshKeyId),
|
||||||
eq(server.organizationId, ctx.session.activeOrganizationId),
|
eq(server.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
eq(server.serverType, "deploy"),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
buildServers: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
const result = await db.query.server.findMany({
|
||||||
|
orderBy: desc(server.createdAt),
|
||||||
|
where: IS_CLOUD
|
||||||
|
? and(
|
||||||
|
isNotNull(server.sshKeyId),
|
||||||
|
eq(server.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
eq(server.serverStatus, "active"),
|
||||||
|
eq(server.serverType, "build"),
|
||||||
|
)
|
||||||
|
: and(
|
||||||
|
isNotNull(server.sshKeyId),
|
||||||
|
eq(server.organizationId, ctx.session.activeOrganizationId),
|
||||||
|
eq(server.serverType, "build"),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
@@ -383,6 +405,15 @@ export const serverRouter = createTRPCRouter({
|
|||||||
const ip = await getPublicIpWithFallback();
|
const ip = await getPublicIpWithFallback();
|
||||||
return ip;
|
return ip;
|
||||||
}),
|
}),
|
||||||
|
getServerTime: protectedProcedure.query(() => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
time: new Date(),
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
|
};
|
||||||
|
}),
|
||||||
getServerMetrics: protectedProcedure
|
getServerMetrics: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -587,7 +587,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
return ports.some((port) => port.targetPort === 8080);
|
return ports.some((port) => port.targetPort === 8080);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
readStatsLogs: adminProcedure
|
readStatsLogs: protectedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
path: "/read-stats-logs",
|
path: "/read-stats-logs",
|
||||||
@@ -650,7 +650,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
|
const processedLogs = processLogs(rawConfig as string, input?.dateRange);
|
||||||
return processedLogs || [];
|
return processedLogs || [];
|
||||||
}),
|
}),
|
||||||
haveActivateRequests: adminProcedure.query(async () => {
|
haveActivateRequests: protectedProcedure.query(async () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -665,7 +665,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return !!parsedConfig?.accessLog?.filePath;
|
return !!parsedConfig?.accessLog?.filePath;
|
||||||
}),
|
}),
|
||||||
toggleRequests: adminProcedure
|
toggleRequests: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
@@ -835,7 +835,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
const ports = await readPorts("dokploy-traefik", input?.serverId);
|
const ports = await readPorts("dokploy-traefik", input?.serverId);
|
||||||
return ports;
|
return ports;
|
||||||
}),
|
}),
|
||||||
updateLogCleanup: adminProcedure
|
updateLogCleanup: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
cronExpression: z.string().nullable(),
|
cronExpression: z.string().nullable(),
|
||||||
@@ -851,7 +851,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
return stopLogCleanup();
|
return stopLogCleanup();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getLogCleanupStatus: adminProcedure.query(async () => {
|
getLogCleanupStatus: protectedProcedure.query(async () => {
|
||||||
return getLogCleanupStatus();
|
return getLogCleanupStatus();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
findNotificationById,
|
findNotificationById,
|
||||||
findOrganizationById,
|
findOrganizationById,
|
||||||
findUserById,
|
findUserById,
|
||||||
|
getDokployUrl,
|
||||||
getUserByToken,
|
getUserByToken,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
removeUserById,
|
removeUserById,
|
||||||
@@ -419,11 +420,10 @@ export const userRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const admin = await findAdmin();
|
|
||||||
const host =
|
const host =
|
||||||
process.env.NODE_ENV === "development"
|
process.env.NODE_ENV === "development"
|
||||||
? "http://localhost:3000"
|
? "http://localhost:3000"
|
||||||
: admin.user.host;
|
: await getDokployUrl();
|
||||||
const inviteLink = `${host}/invitation?token=${input.invitationId}`;
|
const inviteLink = `${host}/invitation?token=${input.invitationId}`;
|
||||||
|
|
||||||
const organization = await findOrganizationById(
|
const organization = await findOrganizationById(
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const domain = z
|
export const domain = z
|
||||||
.object({
|
.object({
|
||||||
host: z.string().min(1, { message: "Add a hostname" }),
|
host: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Add a hostname" })
|
||||||
|
.refine((val) => val === val.trim(), {
|
||||||
|
message: "Domain name cannot have leading or trailing spaces",
|
||||||
|
})
|
||||||
|
.transform((val) => val.trim()),
|
||||||
path: z.string().min(1).optional(),
|
path: z.string().min(1).optional(),
|
||||||
port: z
|
port: z
|
||||||
.number()
|
.number()
|
||||||
@@ -33,7 +39,13 @@ export const domain = z
|
|||||||
|
|
||||||
export const domainCompose = z
|
export const domainCompose = z
|
||||||
.object({
|
.object({
|
||||||
host: z.string().min(1, { message: "Host is required" }),
|
host: z
|
||||||
|
.string()
|
||||||
|
.min(1, { message: "Add a hostname" })
|
||||||
|
.refine((val) => val === val.trim(), {
|
||||||
|
message: "Domain name cannot have leading or trailing spaces",
|
||||||
|
})
|
||||||
|
.transform((val) => val.trim()),
|
||||||
path: z.string().min(1).optional(),
|
path: z.string().min(1).optional(),
|
||||||
port: z
|
port: z
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@@ -2,13 +2,8 @@ import {
|
|||||||
deployApplication,
|
deployApplication,
|
||||||
deployCompose,
|
deployCompose,
|
||||||
deployPreviewApplication,
|
deployPreviewApplication,
|
||||||
deployRemoteApplication,
|
|
||||||
deployRemoteCompose,
|
|
||||||
deployRemotePreviewApplication,
|
|
||||||
rebuildApplication,
|
rebuildApplication,
|
||||||
rebuildCompose,
|
rebuildCompose,
|
||||||
rebuildRemoteApplication,
|
|
||||||
rebuildRemoteCompose,
|
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -24,91 +19,48 @@ export const deploymentWorker = new Worker(
|
|||||||
if (job.data.applicationType === "application") {
|
if (job.data.applicationType === "application") {
|
||||||
await updateApplicationStatus(job.data.applicationId, "running");
|
await updateApplicationStatus(job.data.applicationId, "running");
|
||||||
|
|
||||||
if (job.data.server) {
|
if (job.data.type === "redeploy") {
|
||||||
if (job.data.type === "redeploy") {
|
await rebuildApplication({
|
||||||
await rebuildRemoteApplication({
|
applicationId: job.data.applicationId,
|
||||||
applicationId: job.data.applicationId,
|
titleLog: job.data.titleLog,
|
||||||
titleLog: job.data.titleLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
});
|
||||||
});
|
} else if (job.data.type === "deploy") {
|
||||||
} else if (job.data.type === "deploy") {
|
await deployApplication({
|
||||||
await deployRemoteApplication({
|
applicationId: job.data.applicationId,
|
||||||
applicationId: job.data.applicationId,
|
titleLog: job.data.titleLog,
|
||||||
titleLog: job.data.titleLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (job.data.type === "redeploy") {
|
|
||||||
await rebuildApplication({
|
|
||||||
applicationId: job.data.applicationId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
});
|
|
||||||
} else if (job.data.type === "deploy") {
|
|
||||||
await deployApplication({
|
|
||||||
applicationId: job.data.applicationId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (job.data.applicationType === "compose") {
|
} else if (job.data.applicationType === "compose") {
|
||||||
await updateCompose(job.data.composeId, {
|
await updateCompose(job.data.composeId, {
|
||||||
composeStatus: "running",
|
composeStatus: "running",
|
||||||
});
|
});
|
||||||
|
if (job.data.type === "deploy") {
|
||||||
if (job.data.server) {
|
await deployCompose({
|
||||||
if (job.data.type === "redeploy") {
|
composeId: job.data.composeId,
|
||||||
await rebuildRemoteCompose({
|
titleLog: job.data.titleLog,
|
||||||
composeId: job.data.composeId,
|
descriptionLog: job.data.descriptionLog,
|
||||||
titleLog: job.data.titleLog,
|
});
|
||||||
descriptionLog: job.data.descriptionLog,
|
} else if (job.data.type === "redeploy") {
|
||||||
});
|
await rebuildCompose({
|
||||||
} else if (job.data.type === "deploy") {
|
composeId: job.data.composeId,
|
||||||
await deployRemoteCompose({
|
titleLog: job.data.titleLog,
|
||||||
composeId: job.data.composeId,
|
descriptionLog: job.data.descriptionLog,
|
||||||
titleLog: job.data.titleLog,
|
});
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (job.data.type === "deploy") {
|
|
||||||
await deployCompose({
|
|
||||||
composeId: job.data.composeId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
});
|
|
||||||
} else if (job.data.type === "redeploy") {
|
|
||||||
await rebuildCompose({
|
|
||||||
composeId: job.data.composeId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (job.data.applicationType === "application-preview") {
|
} else if (job.data.applicationType === "application-preview") {
|
||||||
await updatePreviewDeployment(job.data.previewDeploymentId, {
|
await updatePreviewDeployment(job.data.previewDeploymentId, {
|
||||||
previewStatus: "running",
|
previewStatus: "running",
|
||||||
});
|
});
|
||||||
if (job.data.server) {
|
|
||||||
if (job.data.type === "deploy") {
|
if (job.data.type === "deploy") {
|
||||||
await deployRemotePreviewApplication({
|
await deployPreviewApplication({
|
||||||
applicationId: job.data.applicationId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
descriptionLog: job.data.descriptionLog,
|
descriptionLog: job.data.descriptionLog,
|
||||||
previewDeploymentId: job.data.previewDeploymentId,
|
previewDeploymentId: job.data.previewDeploymentId,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (job.data.type === "deploy") {
|
|
||||||
await deployPreviewApplication({
|
|
||||||
applicationId: job.data.applicationId,
|
|
||||||
titleLog: job.data.titleLog,
|
|
||||||
descriptionLog: job.data.descriptionLog,
|
|
||||||
previewDeploymentId: job.data.previewDeploymentId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
execAsync,
|
||||||
|
execAsyncRemote,
|
||||||
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
import { redisConfig } from "./redis-connection";
|
import { redisConfig } from "./redis-connection";
|
||||||
|
|
||||||
@@ -41,4 +45,31 @@ export const cleanQueuesByCompose = async (composeId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const killDockerBuild = async (
|
||||||
|
type: "application" | "compose",
|
||||||
|
serverId: string | null,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (type === "application") {
|
||||||
|
const command = `pkill -2 -f "docker build"`;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
} else if (type === "compose") {
|
||||||
|
const command = `pkill -2 -f "docker compose"`;
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export { myQueue };
|
export { myQueue };
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set up keep-alive ping mechanism to prevent timeout
|
||||||
|
// Send ping every 45 seconds to keep connection alive
|
||||||
|
const pingInterval = setInterval(() => {
|
||||||
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
ws.ping();
|
||||||
|
}
|
||||||
|
}, 45000); // 45 seconds
|
||||||
try {
|
try {
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
const server = await findServerById(serverId);
|
const server = await findServerById(serverId);
|
||||||
@@ -86,6 +94,7 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
.on("error", (err) => {
|
.on("error", (err) => {
|
||||||
console.error("SSH connection error:", err);
|
console.error("SSH connection error:", err);
|
||||||
ws.send(`SSH error: ${err.message}`);
|
ws.send(`SSH error: ${err.message}`);
|
||||||
|
clearInterval(pingInterval);
|
||||||
ws.close(); // Cierra el WebSocket si hay un error con SSH
|
ws.close(); // Cierra el WebSocket si hay un error con SSH
|
||||||
client.end();
|
client.end();
|
||||||
})
|
})
|
||||||
@@ -96,6 +105,7 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
privateKey: server.sshKey?.privateKey,
|
privateKey: server.sshKey?.privateKey,
|
||||||
});
|
});
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
|
clearInterval(pingInterval);
|
||||||
client.end();
|
client.end();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -121,6 +131,7 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
ws.send(data);
|
ws.send(data);
|
||||||
});
|
});
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
|
clearInterval(pingInterval);
|
||||||
ptyProcess.kill();
|
ptyProcess.kill();
|
||||||
});
|
});
|
||||||
ws.on("message", (message) => {
|
ws.on("message", (message) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type http from "node:http";
|
|||||||
import {
|
import {
|
||||||
docker,
|
docker,
|
||||||
execAsync,
|
execAsync,
|
||||||
|
getHostSystemStats,
|
||||||
getLastAdvancedStatsFile,
|
getLastAdvancedStatsFile,
|
||||||
recordAdvancedStats,
|
recordAdvancedStats,
|
||||||
validateRequest,
|
validateRequest,
|
||||||
@@ -49,6 +50,21 @@ export const setupDockerStatsMonitoringSocketServer = (
|
|||||||
}
|
}
|
||||||
const intervalId = setInterval(async () => {
|
const intervalId = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
|
// Special case: when monitoring "dokploy", get host system stats instead of container stats
|
||||||
|
if (appName === "dokploy") {
|
||||||
|
const stat = await getHostSystemStats();
|
||||||
|
|
||||||
|
await recordAdvancedStats(stat, appName);
|
||||||
|
const data = await getLastAdvancedStatsFile(appName);
|
||||||
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
data,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const filter = {
|
const filter = {
|
||||||
status: ["running"],
|
status: ["running"],
|
||||||
...(appType === "application" && {
|
...(appType === "application" && {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
await initializeNetwork();
|
await initializeNetwork();
|
||||||
createDefaultTraefikConfig();
|
createDefaultTraefikConfig();
|
||||||
createDefaultServerTraefikConfig();
|
createDefaultServerTraefikConfig();
|
||||||
await execAsync("docker pull traefik:v3.5.0");
|
await execAsync("docker pull traefik:v3.6.1");
|
||||||
await initializeStandaloneTraefik();
|
await initializeStandaloneTraefik();
|
||||||
await initializeRedis();
|
await initializeRedis();
|
||||||
await initializePostgres();
|
await initializePostgres();
|
||||||
|
|||||||
20004
openapi.json
Normal file
20004
openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,8 @@
|
|||||||
"build": "pnpm -r run build",
|
"build": "pnpm -r run build",
|
||||||
"format-and-lint": "biome check .",
|
"format-and-lint": "biome check .",
|
||||||
"check": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true",
|
"check": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true",
|
||||||
"format-and-lint:fix": "biome check . --write"
|
"format-and-lint:fix": "biome check . --write",
|
||||||
|
"generate:openapi": "pnpm --filter=dokploy run generate:openapi"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.1.1",
|
"@biomejs/biome": "2.1.1",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// boolean,
|
// boolean,
|
||||||
// } from "drizzle-orm/pg-core";
|
// } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
// export const users_temp = pgTable("users_temp", {
|
// export const user = pgTable("user", {
|
||||||
// id: text("id").primaryKey(),
|
// id: text("id").primaryKey(),
|
||||||
// name: text("name").notNull(),
|
// name: text("name").notNull(),
|
||||||
// email: text("email").notNull().unique(),
|
// email: text("email").notNull().unique(),
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
// userAgent: text("user_agent"),
|
// userAgent: text("user_agent"),
|
||||||
// userId: text("user_id")
|
// userId: text("user_id")
|
||||||
// .notNull()
|
// .notNull()
|
||||||
// .references(() => users_temp.id, { onDelete: "cascade" }),
|
// .references(() => user.id, { onDelete: "cascade" }),
|
||||||
// activeOrganizationId: text("active_organization_id"),
|
// activeOrganizationId: text("active_organization_id"),
|
||||||
// });
|
// });
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
// providerId: text("provider_id").notNull(),
|
// providerId: text("provider_id").notNull(),
|
||||||
// userId: text("user_id")
|
// userId: text("user_id")
|
||||||
// .notNull()
|
// .notNull()
|
||||||
// .references(() => users_temp.id, { onDelete: "cascade" }),
|
// .references(() => user.id, { onDelete: "cascade" }),
|
||||||
// accessToken: text("access_token"),
|
// accessToken: text("access_token"),
|
||||||
// refreshToken: text("refresh_token"),
|
// refreshToken: text("refresh_token"),
|
||||||
// idToken: text("id_token"),
|
// idToken: text("id_token"),
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"node-os-utils": "1.3.7",
|
"node-os-utils": "2.0.1",
|
||||||
"node-pty": "1.0.0",
|
"node-pty": "1.0.0",
|
||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "6.9.14",
|
||||||
@@ -75,6 +75,7 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"rotating-file-stream": "3.2.3",
|
"rotating-file-stream": "3.2.3",
|
||||||
|
"shell-quote": "^1.8.1",
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
"toml": "3.0.0",
|
"toml": "3.0.0",
|
||||||
@@ -88,12 +89,12 @@
|
|||||||
"@types/lodash": "4.17.4",
|
"@types/lodash": "4.17.4",
|
||||||
"@types/micromatch": "4.0.9",
|
"@types/micromatch": "4.0.9",
|
||||||
"@types/node": "^18.19.104",
|
"@types/node": "^18.19.104",
|
||||||
"@types/node-os-utils": "1.3.4",
|
|
||||||
"@types/node-schedule": "2.1.6",
|
"@types/node-schedule": "2.1.6",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/qrcode": "^1.5.5",
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@types/ssh2": "1.15.1",
|
"@types/ssh2": "1.15.1",
|
||||||
"@types/ws": "8.5.10",
|
"@types/ws": "8.5.10",
|
||||||
"drizzle-kit": "^0.30.6",
|
"drizzle-kit": "^0.30.6",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { projects } from "./project";
|
import { projects } from "./project";
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import { users_temp } from "./user";
|
import { user } from "./user";
|
||||||
|
|
||||||
export const account = pgTable("account", {
|
export const account = pgTable("account", {
|
||||||
id: text("id")
|
id: text("id")
|
||||||
@@ -21,7 +21,7 @@ export const account = pgTable("account", {
|
|||||||
providerId: text("provider_id").notNull(),
|
providerId: text("provider_id").notNull(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users_temp.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
accessToken: text("access_token"),
|
accessToken: text("access_token"),
|
||||||
refreshToken: text("refresh_token"),
|
refreshToken: text("refresh_token"),
|
||||||
idToken: text("id_token"),
|
idToken: text("id_token"),
|
||||||
@@ -39,9 +39,9 @@ export const account = pgTable("account", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({ one }) => ({
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
user: one(users_temp, {
|
user: one(user, {
|
||||||
fields: [account.userId],
|
fields: [account.userId],
|
||||||
references: [users_temp.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -65,15 +65,15 @@ export const organization = pgTable("organization", {
|
|||||||
metadata: text("metadata"),
|
metadata: text("metadata"),
|
||||||
ownerId: text("owner_id")
|
ownerId: text("owner_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users_temp.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const organizationRelations = relations(
|
export const organizationRelations = relations(
|
||||||
organization,
|
organization,
|
||||||
({ one, many }) => ({
|
({ one, many }) => ({
|
||||||
owner: one(users_temp, {
|
owner: one(user, {
|
||||||
fields: [organization.ownerId],
|
fields: [organization.ownerId],
|
||||||
references: [users_temp.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
servers: many(server),
|
servers: many(server),
|
||||||
projects: many(projects),
|
projects: many(projects),
|
||||||
@@ -90,10 +90,11 @@ export const member = pgTable("member", {
|
|||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users_temp.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
role: text("role").notNull().$type<"owner" | "member" | "admin">(),
|
role: text("role").notNull().$type<"owner" | "member" | "admin">(),
|
||||||
createdAt: timestamp("created_at").notNull(),
|
createdAt: timestamp("created_at").notNull(),
|
||||||
teamId: text("team_id"),
|
teamId: text("team_id"),
|
||||||
|
isDefault: boolean("is_default").notNull().default(false),
|
||||||
// Permissions
|
// Permissions
|
||||||
canCreateProjects: boolean("canCreateProjects").notNull().default(false),
|
canCreateProjects: boolean("canCreateProjects").notNull().default(false),
|
||||||
canAccessToSSHKeys: boolean("canAccessToSSHKeys").notNull().default(false),
|
canAccessToSSHKeys: boolean("canAccessToSSHKeys").notNull().default(false),
|
||||||
@@ -133,9 +134,9 @@ export const memberRelations = relations(member, ({ one }) => ({
|
|||||||
fields: [member.organizationId],
|
fields: [member.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
}),
|
}),
|
||||||
user: one(users_temp, {
|
user: one(user, {
|
||||||
fields: [member.userId],
|
fields: [member.userId],
|
||||||
references: [users_temp.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ export const invitation = pgTable("invitation", {
|
|||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
inviterId: text("inviter_id")
|
inviterId: text("inviter_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users_temp.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
teamId: text("team_id"),
|
teamId: text("team_id"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,7 +168,7 @@ export const twoFactor = pgTable("two_factor", {
|
|||||||
backupCodes: text("backup_codes").notNull(),
|
backupCodes: text("backup_codes").notNull(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users_temp.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apikey = pgTable("apikey", {
|
export const apikey = pgTable("apikey", {
|
||||||
@@ -178,7 +179,7 @@ export const apikey = pgTable("apikey", {
|
|||||||
key: text("key").notNull(),
|
key: text("key").notNull(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users_temp.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
refillInterval: integer("refill_interval"),
|
refillInterval: integer("refill_interval"),
|
||||||
refillAmount: integer("refill_amount"),
|
refillAmount: integer("refill_amount"),
|
||||||
lastRefillAt: timestamp("last_refill_at"),
|
lastRefillAt: timestamp("last_refill_at"),
|
||||||
@@ -197,8 +198,8 @@ export const apikey = pgTable("apikey", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const apikeyRelations = relations(apikey, ({ one }) => ({
|
export const apikeyRelations = relations(apikey, ({ one }) => ({
|
||||||
user: one(users_temp, {
|
user: one(user, {
|
||||||
fields: [apikey.userId],
|
fields: [apikey.userId],
|
||||||
references: [users_temp.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { server } from "./server";
|
|||||||
import {
|
import {
|
||||||
applicationStatus,
|
applicationStatus,
|
||||||
certificateType,
|
certificateType,
|
||||||
|
type EndpointSpecSwarm,
|
||||||
|
EndpointSpecSwarmSchema,
|
||||||
type HealthCheckSwarm,
|
type HealthCheckSwarm,
|
||||||
HealthCheckSwarmSchema,
|
HealthCheckSwarmSchema,
|
||||||
type LabelsSwarm,
|
type LabelsSwarm,
|
||||||
@@ -109,6 +111,7 @@ export const applications = pgTable("application", {
|
|||||||
enabled: boolean("enabled"),
|
enabled: boolean("enabled"),
|
||||||
subtitle: text("subtitle"),
|
subtitle: text("subtitle"),
|
||||||
command: text("command"),
|
command: text("command"),
|
||||||
|
args: text("args").array(),
|
||||||
refreshToken: text("refreshToken").$defaultFn(() => nanoid()),
|
refreshToken: text("refreshToken").$defaultFn(() => nanoid()),
|
||||||
sourceType: sourceType("sourceType").notNull().default("github"),
|
sourceType: sourceType("sourceType").notNull().default("github"),
|
||||||
cleanCache: boolean("cleanCache").default(false),
|
cleanCache: boolean("cleanCache").default(false),
|
||||||
@@ -167,6 +170,7 @@ export const applications = pgTable("application", {
|
|||||||
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
||||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||||
|
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||||
//
|
//
|
||||||
replicas: integer("replicas").default(1).notNull(),
|
replicas: integer("replicas").default(1).notNull(),
|
||||||
applicationStatus: applicationStatus("applicationStatus")
|
applicationStatus: applicationStatus("applicationStatus")
|
||||||
@@ -201,6 +205,15 @@ export const applications = pgTable("application", {
|
|||||||
serverId: text("serverId").references(() => server.serverId, {
|
serverId: text("serverId").references(() => server.serverId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
|
buildServerId: text("buildServerId").references(() => server.serverId, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
buildRegistryId: text("buildRegistryId").references(
|
||||||
|
() => registry.registryId,
|
||||||
|
{
|
||||||
|
onDelete: "set null",
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const applicationsRelations = relations(
|
export const applicationsRelations = relations(
|
||||||
@@ -223,6 +236,7 @@ export const applicationsRelations = relations(
|
|||||||
registry: one(registry, {
|
registry: one(registry, {
|
||||||
fields: [applications.registryId],
|
fields: [applications.registryId],
|
||||||
references: [registry.registryId],
|
references: [registry.registryId],
|
||||||
|
relationName: "applicationRegistry",
|
||||||
}),
|
}),
|
||||||
github: one(github, {
|
github: one(github, {
|
||||||
fields: [applications.githubId],
|
fields: [applications.githubId],
|
||||||
@@ -243,6 +257,17 @@ export const applicationsRelations = relations(
|
|||||||
server: one(server, {
|
server: one(server, {
|
||||||
fields: [applications.serverId],
|
fields: [applications.serverId],
|
||||||
references: [server.serverId],
|
references: [server.serverId],
|
||||||
|
relationName: "applicationServer",
|
||||||
|
}),
|
||||||
|
buildServer: one(server, {
|
||||||
|
fields: [applications.buildServerId],
|
||||||
|
references: [server.serverId],
|
||||||
|
relationName: "applicationBuildServer",
|
||||||
|
}),
|
||||||
|
buildRegistry: one(registry, {
|
||||||
|
fields: [applications.buildRegistryId],
|
||||||
|
references: [registry.registryId],
|
||||||
|
relationName: "applicationBuildRegistry",
|
||||||
}),
|
}),
|
||||||
previewDeployments: many(previewDeployments),
|
previewDeployments: many(previewDeployments),
|
||||||
}),
|
}),
|
||||||
@@ -269,6 +294,7 @@ const createSchema = createInsertSchema(applications, {
|
|||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
isPreviewDeploymentsActive: z.boolean().optional(),
|
isPreviewDeploymentsActive: z.boolean().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
|
args: z.array(z.string()).optional(),
|
||||||
registryUrl: z.string().optional(),
|
registryUrl: z.string().optional(),
|
||||||
customGitSSHKeyId: z.string().optional(),
|
customGitSSHKeyId: z.string().optional(),
|
||||||
repository: z.string().optional(),
|
repository: z.string().optional(),
|
||||||
@@ -318,6 +344,7 @@ const createSchema = createInsertSchema(applications, {
|
|||||||
previewLabels: z.array(z.string()).optional(),
|
previewLabels: z.array(z.string()).optional(),
|
||||||
cleanCache: z.boolean().optional(),
|
cleanCache: z.boolean().optional(),
|
||||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||||
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateApplication = createSchema.pick({
|
export const apiCreateApplication = createSchema.pick({
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { mariadb } from "./mariadb";
|
|||||||
import { mongo } from "./mongo";
|
import { mongo } from "./mongo";
|
||||||
import { mysql } from "./mysql";
|
import { mysql } from "./mysql";
|
||||||
import { postgres } from "./postgres";
|
import { postgres } from "./postgres";
|
||||||
import { users_temp } from "./user";
|
import { user } from "./user";
|
||||||
export const databaseType = pgEnum("databaseType", [
|
export const databaseType = pgEnum("databaseType", [
|
||||||
"postgres",
|
"postgres",
|
||||||
"mariadb",
|
"mariadb",
|
||||||
@@ -74,7 +74,7 @@ export const backups = pgTable("backup", {
|
|||||||
mongoId: text("mongoId").references((): AnyPgColumn => mongo.mongoId, {
|
mongoId: text("mongoId").references((): AnyPgColumn => mongo.mongoId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
userId: text("userId").references(() => users_temp.id),
|
userId: text("userId").references(() => user.id),
|
||||||
// Only for compose backups
|
// Only for compose backups
|
||||||
metadata: jsonb("metadata").$type<
|
metadata: jsonb("metadata").$type<
|
||||||
| {
|
| {
|
||||||
@@ -118,9 +118,9 @@ export const backupsRelations = relations(backups, ({ one, many }) => ({
|
|||||||
fields: [backups.mongoId],
|
fields: [backups.mongoId],
|
||||||
references: [mongo.mongoId],
|
references: [mongo.mongoId],
|
||||||
}),
|
}),
|
||||||
user: one(users_temp, {
|
user: one(user, {
|
||||||
fields: [backups.userId],
|
fields: [backups.userId],
|
||||||
references: [users_temp.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
compose: one(compose, {
|
compose: one(compose, {
|
||||||
fields: [backups.composeId],
|
fields: [backups.composeId],
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export const deployments = pgTable("deployment", {
|
|||||||
(): AnyPgColumn => volumeBackups.volumeBackupId,
|
(): AnyPgColumn => volumeBackups.volumeBackupId,
|
||||||
{ onDelete: "cascade" },
|
{ onDelete: "cascade" },
|
||||||
),
|
),
|
||||||
|
buildServerId: text("buildServerId").references(() => server.serverId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||||
@@ -84,6 +87,12 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
|||||||
server: one(server, {
|
server: one(server, {
|
||||||
fields: [deployments.serverId],
|
fields: [deployments.serverId],
|
||||||
references: [server.serverId],
|
references: [server.serverId],
|
||||||
|
relationName: "deploymentServer",
|
||||||
|
}),
|
||||||
|
buildServer: one(server, {
|
||||||
|
fields: [deployments.buildServerId],
|
||||||
|
references: [server.serverId],
|
||||||
|
relationName: "deploymentBuildServer",
|
||||||
}),
|
}),
|
||||||
previewDeployment: one(previewDeployments, {
|
previewDeployment: one(previewDeployments, {
|
||||||
fields: [deployments.previewDeploymentId],
|
fields: [deployments.previewDeploymentId],
|
||||||
@@ -115,6 +124,7 @@ const schema = createInsertSchema(deployments, {
|
|||||||
composeId: z.string(),
|
composeId: z.string(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
previewDeploymentId: z.string(),
|
previewDeploymentId: z.string(),
|
||||||
|
buildServerId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateDeployment = schema
|
export const apiCreateDeployment = schema
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { bitbucket } from "./bitbucket";
|
|||||||
import { gitea } from "./gitea";
|
import { gitea } from "./gitea";
|
||||||
import { github } from "./github";
|
import { github } from "./github";
|
||||||
import { gitlab } from "./gitlab";
|
import { gitlab } from "./gitlab";
|
||||||
import { users_temp } from "./user";
|
import { user } from "./user";
|
||||||
|
|
||||||
export const gitProviderType = pgEnum("gitProviderType", [
|
export const gitProviderType = pgEnum("gitProviderType", [
|
||||||
"github",
|
"github",
|
||||||
@@ -32,7 +32,7 @@ export const gitProvider = pgTable("git_provider", {
|
|||||||
.references(() => organization.id, { onDelete: "cascade" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
userId: text("userId")
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users_temp.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
|
export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
|
||||||
@@ -56,9 +56,9 @@ export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
|
|||||||
fields: [gitProvider.organizationId],
|
fields: [gitProvider.organizationId],
|
||||||
references: [organization.id],
|
references: [organization.id],
|
||||||
}),
|
}),
|
||||||
user: one(users_temp, {
|
user: one(user, {
|
||||||
fields: [gitProvider.userId],
|
fields: [gitProvider.userId],
|
||||||
references: [users_temp.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { mounts } from "./mount";
|
|||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import {
|
import {
|
||||||
applicationStatus,
|
applicationStatus,
|
||||||
|
type EndpointSpecSwarm,
|
||||||
|
EndpointSpecSwarmSchema,
|
||||||
type HealthCheckSwarm,
|
type HealthCheckSwarm,
|
||||||
HealthCheckSwarmSchema,
|
HealthCheckSwarmSchema,
|
||||||
type LabelsSwarm,
|
type LabelsSwarm,
|
||||||
@@ -43,6 +45,7 @@ export const mariadb = pgTable("mariadb", {
|
|||||||
databaseRootPassword: text("rootPassword").notNull(),
|
databaseRootPassword: text("rootPassword").notNull(),
|
||||||
dockerImage: text("dockerImage").notNull(),
|
dockerImage: text("dockerImage").notNull(),
|
||||||
command: text("command"),
|
command: text("command"),
|
||||||
|
args: text("args").array(),
|
||||||
env: text("env"),
|
env: text("env"),
|
||||||
// RESOURCES
|
// RESOURCES
|
||||||
memoryReservation: text("memoryReservation"),
|
memoryReservation: text("memoryReservation"),
|
||||||
@@ -63,6 +66,7 @@ export const mariadb = pgTable("mariadb", {
|
|||||||
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
||||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||||
|
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||||
replicas: integer("replicas").default(1).notNull(),
|
replicas: integer("replicas").default(1).notNull(),
|
||||||
createdAt: text("createdAt")
|
createdAt: text("createdAt")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -111,6 +115,7 @@ const createSchema = createInsertSchema(mariadb, {
|
|||||||
.optional(),
|
.optional(),
|
||||||
dockerImage: z.string().default("mariadb:6"),
|
dockerImage: z.string().default("mariadb:6"),
|
||||||
command: z.string().optional(),
|
command: z.string().optional(),
|
||||||
|
args: z.array(z.string()).optional(),
|
||||||
env: z.string().optional(),
|
env: z.string().optional(),
|
||||||
memoryReservation: z.string().optional(),
|
memoryReservation: z.string().optional(),
|
||||||
memoryLimit: z.string().optional(),
|
memoryLimit: z.string().optional(),
|
||||||
@@ -130,6 +135,7 @@ const createSchema = createInsertSchema(mariadb, {
|
|||||||
labelsSwarm: LabelsSwarmSchema.nullable(),
|
labelsSwarm: LabelsSwarmSchema.nullable(),
|
||||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||||
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateMariaDB = createSchema
|
export const apiCreateMariaDB = createSchema
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { mounts } from "./mount";
|
|||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import {
|
import {
|
||||||
applicationStatus,
|
applicationStatus,
|
||||||
|
type EndpointSpecSwarm,
|
||||||
|
EndpointSpecSwarmSchema,
|
||||||
type HealthCheckSwarm,
|
type HealthCheckSwarm,
|
||||||
HealthCheckSwarmSchema,
|
HealthCheckSwarmSchema,
|
||||||
type LabelsSwarm,
|
type LabelsSwarm,
|
||||||
@@ -48,6 +50,7 @@ export const mongo = pgTable("mongo", {
|
|||||||
databasePassword: text("databasePassword").notNull(),
|
databasePassword: text("databasePassword").notNull(),
|
||||||
dockerImage: text("dockerImage").notNull(),
|
dockerImage: text("dockerImage").notNull(),
|
||||||
command: text("command"),
|
command: text("command"),
|
||||||
|
args: text("args").array(),
|
||||||
env: text("env"),
|
env: text("env"),
|
||||||
memoryReservation: text("memoryReservation"),
|
memoryReservation: text("memoryReservation"),
|
||||||
memoryLimit: text("memoryLimit"),
|
memoryLimit: text("memoryLimit"),
|
||||||
@@ -66,6 +69,7 @@ export const mongo = pgTable("mongo", {
|
|||||||
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
||||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||||
|
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||||
replicas: integer("replicas").default(1).notNull(),
|
replicas: integer("replicas").default(1).notNull(),
|
||||||
createdAt: text("createdAt")
|
createdAt: text("createdAt")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -107,6 +111,7 @@ const createSchema = createInsertSchema(mongo, {
|
|||||||
databaseUser: z.string().min(1),
|
databaseUser: z.string().min(1),
|
||||||
dockerImage: z.string().default("mongo:15"),
|
dockerImage: z.string().default("mongo:15"),
|
||||||
command: z.string().optional(),
|
command: z.string().optional(),
|
||||||
|
args: z.array(z.string()).optional(),
|
||||||
env: z.string().optional(),
|
env: z.string().optional(),
|
||||||
memoryReservation: z.string().optional(),
|
memoryReservation: z.string().optional(),
|
||||||
memoryLimit: z.string().optional(),
|
memoryLimit: z.string().optional(),
|
||||||
@@ -127,6 +132,7 @@ const createSchema = createInsertSchema(mongo, {
|
|||||||
labelsSwarm: LabelsSwarmSchema.nullable(),
|
labelsSwarm: LabelsSwarmSchema.nullable(),
|
||||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||||
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateMongo = createSchema
|
export const apiCreateMongo = createSchema
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { mounts } from "./mount";
|
|||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import {
|
import {
|
||||||
applicationStatus,
|
applicationStatus,
|
||||||
|
type EndpointSpecSwarm,
|
||||||
|
EndpointSpecSwarmSchema,
|
||||||
type HealthCheckSwarm,
|
type HealthCheckSwarm,
|
||||||
HealthCheckSwarmSchema,
|
HealthCheckSwarmSchema,
|
||||||
type LabelsSwarm,
|
type LabelsSwarm,
|
||||||
@@ -43,6 +45,7 @@ export const mysql = pgTable("mysql", {
|
|||||||
databaseRootPassword: text("rootPassword").notNull(),
|
databaseRootPassword: text("rootPassword").notNull(),
|
||||||
dockerImage: text("dockerImage").notNull(),
|
dockerImage: text("dockerImage").notNull(),
|
||||||
command: text("command"),
|
command: text("command"),
|
||||||
|
args: text("args").array(),
|
||||||
env: text("env"),
|
env: text("env"),
|
||||||
memoryReservation: text("memoryReservation"),
|
memoryReservation: text("memoryReservation"),
|
||||||
memoryLimit: text("memoryLimit"),
|
memoryLimit: text("memoryLimit"),
|
||||||
@@ -61,6 +64,7 @@ export const mysql = pgTable("mysql", {
|
|||||||
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
||||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||||
|
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||||
replicas: integer("replicas").default(1).notNull(),
|
replicas: integer("replicas").default(1).notNull(),
|
||||||
createdAt: text("createdAt")
|
createdAt: text("createdAt")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -109,6 +113,7 @@ const createSchema = createInsertSchema(mysql, {
|
|||||||
.optional(),
|
.optional(),
|
||||||
dockerImage: z.string().default("mysql:8"),
|
dockerImage: z.string().default("mysql:8"),
|
||||||
command: z.string().optional(),
|
command: z.string().optional(),
|
||||||
|
args: z.array(z.string()).optional(),
|
||||||
env: z.string().optional(),
|
env: z.string().optional(),
|
||||||
memoryReservation: z.string().optional(),
|
memoryReservation: z.string().optional(),
|
||||||
memoryLimit: z.string().optional(),
|
memoryLimit: z.string().optional(),
|
||||||
@@ -127,6 +132,7 @@ const createSchema = createInsertSchema(mysql, {
|
|||||||
labelsSwarm: LabelsSwarmSchema.nullable(),
|
labelsSwarm: LabelsSwarmSchema.nullable(),
|
||||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||||
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateMySql = createSchema
|
export const apiCreateMySql = createSchema
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export const ntfy = pgTable("ntfy", {
|
|||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
serverUrl: text("serverUrl").notNull(),
|
serverUrl: text("serverUrl").notNull(),
|
||||||
topic: text("topic").notNull(),
|
topic: text("topic").notNull(),
|
||||||
accessToken: text("accessToken").notNull(),
|
accessToken: text("accessToken"),
|
||||||
priority: integer("priority").notNull().default(3),
|
priority: integer("priority").notNull().default(3),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -331,7 +331,7 @@ export const apiCreateNtfy = notificationsSchema
|
|||||||
.extend({
|
.extend({
|
||||||
serverUrl: z.string().min(1),
|
serverUrl: z.string().min(1),
|
||||||
topic: z.string().min(1),
|
topic: z.string().min(1),
|
||||||
accessToken: z.string().min(1),
|
accessToken: z.string().optional(),
|
||||||
priority: z.number().min(1),
|
priority: z.number().min(1),
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
@@ -395,7 +395,7 @@ export const apiSendTest = notificationsSchema
|
|||||||
serverUrl: z.string(),
|
serverUrl: z.string(),
|
||||||
topic: z.string(),
|
topic: z.string(),
|
||||||
appToken: z.string(),
|
appToken: z.string(),
|
||||||
accessToken: z.string(),
|
accessToken: z.string().optional(),
|
||||||
priority: z.number(),
|
priority: z.number(),
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { mounts } from "./mount";
|
|||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import {
|
import {
|
||||||
applicationStatus,
|
applicationStatus,
|
||||||
|
type EndpointSpecSwarm,
|
||||||
|
EndpointSpecSwarmSchema,
|
||||||
type HealthCheckSwarm,
|
type HealthCheckSwarm,
|
||||||
HealthCheckSwarmSchema,
|
HealthCheckSwarmSchema,
|
||||||
type LabelsSwarm,
|
type LabelsSwarm,
|
||||||
@@ -42,6 +44,7 @@ export const postgres = pgTable("postgres", {
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
dockerImage: text("dockerImage").notNull(),
|
dockerImage: text("dockerImage").notNull(),
|
||||||
command: text("command"),
|
command: text("command"),
|
||||||
|
args: text("args").array(),
|
||||||
env: text("env"),
|
env: text("env"),
|
||||||
memoryReservation: text("memoryReservation"),
|
memoryReservation: text("memoryReservation"),
|
||||||
externalPort: integer("externalPort"),
|
externalPort: integer("externalPort"),
|
||||||
@@ -61,6 +64,7 @@ export const postgres = pgTable("postgres", {
|
|||||||
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
||||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||||
|
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||||
replicas: integer("replicas").default(1).notNull(),
|
replicas: integer("replicas").default(1).notNull(),
|
||||||
createdAt: text("createdAt")
|
createdAt: text("createdAt")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -100,6 +104,7 @@ const createSchema = createInsertSchema(postgres, {
|
|||||||
databaseUser: z.string().min(1),
|
databaseUser: z.string().min(1),
|
||||||
dockerImage: z.string().default("postgres:15"),
|
dockerImage: z.string().default("postgres:15"),
|
||||||
command: z.string().optional(),
|
command: z.string().optional(),
|
||||||
|
args: z.array(z.string()).optional(),
|
||||||
env: z.string().optional(),
|
env: z.string().optional(),
|
||||||
memoryReservation: z.string().optional(),
|
memoryReservation: z.string().optional(),
|
||||||
memoryLimit: z.string().optional(),
|
memoryLimit: z.string().optional(),
|
||||||
@@ -120,6 +125,7 @@ const createSchema = createInsertSchema(postgres, {
|
|||||||
labelsSwarm: LabelsSwarmSchema.nullable(),
|
labelsSwarm: LabelsSwarmSchema.nullable(),
|
||||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||||
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreatePostgres = createSchema
|
export const apiCreatePostgres = createSchema
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { nanoid } from "nanoid";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { environments } from "./environment";
|
import { environments } from "./environment";
|
||||||
import { mounts } from "./mount";
|
import { mounts } from "./mount";
|
||||||
import { projects } from "./project";
|
|
||||||
import { server } from "./server";
|
import { server } from "./server";
|
||||||
import {
|
import {
|
||||||
applicationStatus,
|
applicationStatus,
|
||||||
|
type EndpointSpecSwarm,
|
||||||
|
EndpointSpecSwarmSchema,
|
||||||
type HealthCheckSwarm,
|
type HealthCheckSwarm,
|
||||||
HealthCheckSwarmSchema,
|
HealthCheckSwarmSchema,
|
||||||
type LabelsSwarm,
|
type LabelsSwarm,
|
||||||
@@ -40,6 +41,7 @@ export const redis = pgTable("redis", {
|
|||||||
databasePassword: text("password").notNull(),
|
databasePassword: text("password").notNull(),
|
||||||
dockerImage: text("dockerImage").notNull(),
|
dockerImage: text("dockerImage").notNull(),
|
||||||
command: text("command"),
|
command: text("command"),
|
||||||
|
args: text("args").array(),
|
||||||
env: text("env"),
|
env: text("env"),
|
||||||
memoryReservation: text("memoryReservation"),
|
memoryReservation: text("memoryReservation"),
|
||||||
memoryLimit: text("memoryLimit"),
|
memoryLimit: text("memoryLimit"),
|
||||||
@@ -61,6 +63,7 @@ export const redis = pgTable("redis", {
|
|||||||
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
|
||||||
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
|
||||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
|
||||||
|
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||||
replicas: integer("replicas").default(1).notNull(),
|
replicas: integer("replicas").default(1).notNull(),
|
||||||
|
|
||||||
environmentId: text("environmentId")
|
environmentId: text("environmentId")
|
||||||
@@ -91,6 +94,7 @@ const createSchema = createInsertSchema(redis, {
|
|||||||
databasePassword: z.string(),
|
databasePassword: z.string(),
|
||||||
dockerImage: z.string().default("redis:8"),
|
dockerImage: z.string().default("redis:8"),
|
||||||
command: z.string().optional(),
|
command: z.string().optional(),
|
||||||
|
args: z.array(z.string()).optional(),
|
||||||
env: z.string().optional(),
|
env: z.string().optional(),
|
||||||
memoryReservation: z.string().optional(),
|
memoryReservation: z.string().optional(),
|
||||||
memoryLimit: z.string().optional(),
|
memoryLimit: z.string().optional(),
|
||||||
@@ -110,6 +114,7 @@ const createSchema = createInsertSchema(redis, {
|
|||||||
labelsSwarm: LabelsSwarmSchema.nullable(),
|
labelsSwarm: LabelsSwarmSchema.nullable(),
|
||||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||||
|
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiCreateRedis = createSchema
|
export const apiCreateRedis = createSchema
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ export const registry = pgTable("registry", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const registryRelations = relations(registry, ({ many }) => ({
|
export const registryRelations = relations(registry, ({ many }) => ({
|
||||||
applications: many(applications),
|
applications: many(applications, {
|
||||||
|
relationName: "applicationRegistry",
|
||||||
|
}),
|
||||||
|
buildApplications: many(applications, {
|
||||||
|
relationName: "applicationBuildRegistry",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(registry, {
|
const createSchema = createInsertSchema(registry, {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user