mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-19 22:25:22 +02:00
Compare commits
9 Commits
core-model
...
feat/intro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
847cf6118b | ||
|
|
275d7ee586 | ||
|
|
29aae91959 | ||
|
|
ab6cb7349e | ||
|
|
e36c665e02 | ||
|
|
f3dc480b70 | ||
|
|
6758ef1542 | ||
|
|
bd4ff2dbf2 | ||
|
|
579a2262bf |
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)
|
||||||
|
|
||||||
|
|||||||
BIN
.github/sponsors/awesome.png
vendored
BIN
.github/sponsors/awesome.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB |
26
.github/workflows/pull-request.yml
vendored
26
.github/workflows/pull-request.yml
vendored
@@ -20,32 +20,6 @@ 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.41.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.4
|
|
||||||
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
70
.github/workflows/sync-openapi-docs.yml
vendored
@@ -1,70 +0,0 @@
|
|||||||
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"
|
|
||||||
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -13,8 +13,6 @@ node_modules
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
openapi.json
|
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage
|
coverage
|
||||||
|
|
||||||
@@ -44,6 +42,3 @@ yarn-error.log*
|
|||||||
|
|
||||||
|
|
||||||
.db
|
.db
|
||||||
|
|
||||||
# Development environment
|
|
||||||
.devcontainer
|
|
||||||
@@ -148,7 +148,7 @@ curl -sSL https://railpack.com/install.sh | sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Buildpacks
|
# Install Buildpacks
|
||||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pull Request
|
## Pull Request
|
||||||
|
|||||||
@@ -46,23 +46,23 @@ 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 --version 28.5.2 && 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 && 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
|
||||||
|
|
||||||
ARG NIXPACKS_VERSION=1.41.0
|
ARG NIXPACKS_VERSION=1.39.0
|
||||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||||
&& chmod +x install.sh \
|
&& chmod +x install.sh \
|
||||||
&& ./install.sh \
|
&& ./install.sh \
|
||||||
&& pnpm install -g tsx
|
&& pnpm install -g tsx
|
||||||
|
|
||||||
# Install Railpack
|
# Install Railpack
|
||||||
ARG RAILPACK_VERSION=0.15.4
|
ARG RAILPACK_VERSION=0.2.2
|
||||||
RUN curl -sSL https://railpack.com/install.sh | bash
|
RUN curl -sSL https://railpack.com/install.sh | bash
|
||||||
|
|
||||||
# Install buildpacks
|
# Install buildpacks
|
||||||
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD [ "pnpm", "start" ]
|
CMD [ "pnpm", "start" ]
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
|
|||||||
|
|
||||||
|
|
||||||
# Deploy only the dokploy app
|
# Deploy only the dokploy app
|
||||||
# ARG NEXT_PUBLIC_UMAMI_HOST
|
ARG NEXT_PUBLIC_UMAMI_HOST
|
||||||
# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||||
|
|
||||||
# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
|
|||||||
@@ -77,12 +77,6 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
<div>
|
<div>
|
||||||
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
|
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
|
||||||
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
|
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
|
||||||
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
|
|
||||||
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
|
|
||||||
</a>
|
|
||||||
<a href="https://awesome.tools/" target="_blank">
|
|
||||||
<img src=".github/sponsors/awesome.png" width="200" height="150" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Premium Supporters 🥇 -->
|
<!-- Premium Supporters 🥇 -->
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.14.3",
|
||||||
"@hono/zod-validator": "0.3.0",
|
"@hono/zod-validator": "0.3.0",
|
||||||
|
"@nerimity/mimiqueue": "1.2.3",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"hono": "^4.7.10",
|
"hono": "^4.7.10",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
|||||||
titleLog: z.string().optional(),
|
titleLog: z.string().optional(),
|
||||||
descriptionLog: z.string().optional(),
|
descriptionLog: z.string().optional(),
|
||||||
server: z.boolean().optional(),
|
server: z.boolean().optional(),
|
||||||
type: z.enum(["deploy", "redeploy"]),
|
type: z.enum(["deploy"]),
|
||||||
applicationType: z.literal("application-preview"),
|
applicationType: z.literal("application-preview"),
|
||||||
serverId: z.string().min(1),
|
serverId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
deployApplication,
|
deployRemoteApplication,
|
||||||
deployCompose,
|
deployRemoteCompose,
|
||||||
deployPreviewApplication,
|
deployRemotePreviewApplication,
|
||||||
rebuildApplication,
|
rebuildRemoteApplication,
|
||||||
rebuildCompose,
|
rebuildRemoteCompose,
|
||||||
rebuildPreviewApplication,
|
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -17,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 rebuildApplication({
|
await rebuildRemoteApplication({
|
||||||
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 deployApplication({
|
await deployRemoteApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Manual deployment",
|
titleLog: job.titleLog || "Manual deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
@@ -37,13 +36,13 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
|
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "redeploy") {
|
if (job.type === "redeploy") {
|
||||||
await rebuildCompose({
|
await rebuildRemoteCompose({
|
||||||
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 deployCompose({
|
await deployRemoteCompose({
|
||||||
composeId: job.composeId,
|
composeId: job.composeId,
|
||||||
titleLog: job.titleLog || "Manual deployment",
|
titleLog: job.titleLog || "Manual deployment",
|
||||||
descriptionLog: job.descriptionLog || "",
|
descriptionLog: job.descriptionLog || "",
|
||||||
@@ -55,15 +54,8 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
previewStatus: "running",
|
previewStatus: "running",
|
||||||
});
|
});
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "redeploy") {
|
if (job.type === "deploy") {
|
||||||
await rebuildPreviewApplication({
|
await deployRemotePreviewApplication({
|
||||||
applicationId: job.applicationId,
|
|
||||||
titleLog: job.titleLog || "Rebuild Preview Deployment",
|
|
||||||
descriptionLog: job.descriptionLog || "",
|
|
||||||
previewDeploymentId: job.previewDeploymentId,
|
|
||||||
});
|
|
||||||
} else if (job.type === "deploy") {
|
|
||||||
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,243 +0,0 @@
|
|||||||
import type { Registry } from "@dokploy/server";
|
|
||||||
import { getRegistryTag } from "@dokploy/server";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("getRegistryTag", () => {
|
|
||||||
// Helper to create a mock registry
|
|
||||||
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry => {
|
|
||||||
return {
|
|
||||||
registryId: "test-registry-id",
|
|
||||||
registryName: "Test Registry",
|
|
||||||
username: "myuser",
|
|
||||||
password: "test-password",
|
|
||||||
registryUrl: "docker.io",
|
|
||||||
registryType: "cloud",
|
|
||||||
imagePrefix: null,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
organizationId: "test-org-id",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("with username (no imagePrefix)", () => {
|
|
||||||
it("should handle simple image name without tag", () => {
|
|
||||||
const registry = createMockRegistry({ username: "myuser" });
|
|
||||||
const result = getRegistryTag(registry, "nginx");
|
|
||||||
expect(result).toBe("docker.io/myuser/nginx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle image name with tag", () => {
|
|
||||||
const registry = createMockRegistry({ username: "myuser" });
|
|
||||||
const result = getRegistryTag(registry, "nginx:latest");
|
|
||||||
expect(result).toBe("docker.io/myuser/nginx:latest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle image name with username already present (no duplication)", () => {
|
|
||||||
const registry = createMockRegistry({ username: "myuser" });
|
|
||||||
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
|
||||||
// Should not duplicate username
|
|
||||||
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle image name with username and tag already present", () => {
|
|
||||||
const registry = createMockRegistry({ username: "myuser" });
|
|
||||||
const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
|
|
||||||
// Should not duplicate username
|
|
||||||
expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle complex image name with username", () => {
|
|
||||||
const registry = createMockRegistry({ username: "siumauricio" });
|
|
||||||
const result = getRegistryTag(
|
|
||||||
registry,
|
|
||||||
"siumauricio/app-parse-multi-byte-port-e32uh7",
|
|
||||||
);
|
|
||||||
// Should not duplicate username
|
|
||||||
expect(result).toBe(
|
|
||||||
"docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle image name with different username (should not duplicate)", () => {
|
|
||||||
const registry = createMockRegistry({ username: "myuser" });
|
|
||||||
const result = getRegistryTag(registry, "otheruser/myprivaterepo");
|
|
||||||
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle image name with full registry URL (no username)", () => {
|
|
||||||
const registry = createMockRegistry({ username: "myuser" });
|
|
||||||
const result = getRegistryTag(registry, "docker.io/nginx");
|
|
||||||
// Should add username since imageName doesn't have one
|
|
||||||
expect(result).toBe("docker.io/myuser/nginx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle image name with custom registry URL and username", () => {
|
|
||||||
const registry = createMockRegistry({ username: "myuser" });
|
|
||||||
const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
|
|
||||||
// Should not duplicate username even if registry URL is different
|
|
||||||
expect(result).toBe("docker.io/myuser/repo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle image name with custom registry URL (different username)", () => {
|
|
||||||
const registry = createMockRegistry({ username: "myuser" });
|
|
||||||
const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
|
|
||||||
// Should use registry username, not the one in imageName
|
|
||||||
expect(result).toBe("docker.io/myuser/repo");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("with imagePrefix", () => {
|
|
||||||
it("should use imagePrefix instead of username", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "myuser",
|
|
||||||
imagePrefix: "myorg",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "nginx");
|
|
||||||
expect(result).toBe("docker.io/myorg/nginx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should use imagePrefix with image tag", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "myuser",
|
|
||||||
imagePrefix: "myorg",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "nginx:latest");
|
|
||||||
expect(result).toBe("docker.io/myorg/nginx:latest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle imagePrefix with username already in image name", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "myuser",
|
|
||||||
imagePrefix: "myorg",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
|
||||||
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle imagePrefix matching image name prefix", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "myuser",
|
|
||||||
imagePrefix: "myorg",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "myorg/myprivaterepo");
|
|
||||||
// Should not duplicate prefix
|
|
||||||
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("without registryUrl", () => {
|
|
||||||
it("should work without registryUrl", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "myuser",
|
|
||||||
registryUrl: "",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "nginx");
|
|
||||||
expect(result).toBe("myuser/nginx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should work without registryUrl with imagePrefix", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "myuser",
|
|
||||||
imagePrefix: "myorg",
|
|
||||||
registryUrl: "",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "nginx");
|
|
||||||
expect(result).toBe("myorg/nginx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle username already present without registryUrl", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "myuser",
|
|
||||||
registryUrl: "",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
|
||||||
// Should not duplicate username
|
|
||||||
expect(result).toBe("myuser/myprivaterepo");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("with custom registryUrl", () => {
|
|
||||||
it("should handle custom registry URL", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "myuser",
|
|
||||||
registryUrl: "ghcr.io",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "nginx");
|
|
||||||
expect(result).toBe("ghcr.io/myuser/nginx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle custom registry URL with imagePrefix", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "myuser",
|
|
||||||
imagePrefix: "myorg",
|
|
||||||
registryUrl: "ghcr.io",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "nginx");
|
|
||||||
expect(result).toBe("ghcr.io/myorg/nginx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle custom registry URL with username already present", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "myuser",
|
|
||||||
registryUrl: "ghcr.io",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
|
||||||
// Should not duplicate username
|
|
||||||
expect(result).toBe("ghcr.io/myuser/myprivaterepo");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("edge cases", () => {
|
|
||||||
it("should handle empty image name", () => {
|
|
||||||
const registry = createMockRegistry({ username: "myuser" });
|
|
||||||
const result = getRegistryTag(registry, "");
|
|
||||||
expect(result).toBe("docker.io/myuser/");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle image name with multiple slashes", () => {
|
|
||||||
const registry = createMockRegistry({ username: "myuser" });
|
|
||||||
const result = getRegistryTag(registry, "org/suborg/repo");
|
|
||||||
expect(result).toBe("docker.io/myuser/repo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle image name with username at different position", () => {
|
|
||||||
const registry = createMockRegistry({ username: "myuser" });
|
|
||||||
const result = getRegistryTag(registry, "org/myuser/repo");
|
|
||||||
expect(result).toBe("docker.io/myuser/repo");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("special characters in username", () => {
|
|
||||||
it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "robot$library+dokploy",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "nginx");
|
|
||||||
expect(result).toBe("docker.io/robot$library+dokploy/nginx");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle username with $ and other special characters", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "robot$test+app",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "myapp:latest");
|
|
||||||
expect(result).toBe("docker.io/robot$test+app/myapp:latest");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle username with multiple $ symbols", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "user$name$test",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "app");
|
|
||||||
expect(result).toBe("docker.io/user$name$test/app");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle username with + and - symbols", () => {
|
|
||||||
const registry = createMockRegistry({
|
|
||||||
username: "robot+test-user",
|
|
||||||
});
|
|
||||||
const result = getRegistryTag(registry, "nginx:latest");
|
|
||||||
expect(result).toBe("docker.io/robot+test-user/nginx:latest");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToAllProperties } from "@dokploy/server";
|
import { addSuffixToAllProperties } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile1 = `
|
const composeFile1 = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -61,7 +61,7 @@ secrets:
|
|||||||
file: ./db_password.txt
|
file: ./db_password.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile1 = parse(`
|
const expectedComposeFile1 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -120,7 +120,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all properties in compose file 1", () => {
|
test("Add suffix to all properties in compose file 1", () => {
|
||||||
const composeData = parse(composeFile1) as ComposeSpecification;
|
const composeData = load(composeFile1) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||||
@@ -185,7 +185,7 @@ secrets:
|
|||||||
file: ./db_password.txt
|
file: ./db_password.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile2 = parse(`
|
const expectedComposeFile2 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -243,7 +243,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all properties in compose file 2", () => {
|
test("Add suffix to all properties in compose file 2", () => {
|
||||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
const composeData = load(composeFile2) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||||
@@ -308,7 +308,7 @@ secrets:
|
|||||||
file: ./service_secret.txt
|
file: ./service_secret.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile3 = parse(`
|
const expectedComposeFile3 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -366,7 +366,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all properties in compose file 3", () => {
|
test("Add suffix to all properties in compose file 3", () => {
|
||||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
const composeData = load(composeFile3) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||||
@@ -420,7 +420,7 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile = parse(`
|
const expectedComposeFile = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -467,7 +467,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all properties in Plausible compose file", () => {
|
test("Add suffix to all properties in Plausible compose file", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -23,7 +23,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to configs in root property", () => {
|
test("Add suffix to configs in root property", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to multiple configs in root property", () => {
|
test("Add suffix to multiple configs in root property", () => {
|
||||||
const composeData = parse(composeFileMultipleConfigs) as ComposeSpecification;
|
const composeData = load(composeFileMultipleConfigs) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to configs with different properties in root property", () => {
|
test("Add suffix to configs with different properties in root property", () => {
|
||||||
const composeData = parse(
|
const composeData = load(
|
||||||
composeFileDifferentProperties,
|
composeFileDifferentProperties,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Expected compose file con el prefijo `testhash`
|
// Expected compose file con el prefijo `testhash`
|
||||||
const expectedComposeFileConfigRoot = parse(`
|
const expectedComposeFileConfigRoot = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -162,7 +162,7 @@ configs:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to configs in root property", () => {
|
test("Add suffix to configs in root property", () => {
|
||||||
const composeData = parse(composeFileConfigRoot) as ComposeSpecification;
|
const composeData = load(composeFileConfigRoot) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
addSuffixToConfigsInServices,
|
addSuffixToConfigsInServices,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -22,7 +22,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to configs in services", () => {
|
test("Add suffix to configs in services", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to configs in services with single config", () => {
|
test("Add suffix to configs in services with single config", () => {
|
||||||
const composeData = parse(
|
const composeData = load(
|
||||||
composeFileSingleServiceConfig,
|
composeFileSingleServiceConfig,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ configs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to configs in services with multiple configs", () => {
|
test("Add suffix to configs in services with multiple configs", () => {
|
||||||
const composeData = parse(
|
const composeData = load(
|
||||||
composeFileMultipleServicesConfigs,
|
composeFileMultipleServicesConfigs,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ services:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Expected compose file con el prefijo `testhash`
|
// Expected compose file con el prefijo `testhash`
|
||||||
const expectedComposeFileConfigServices = parse(`
|
const expectedComposeFileConfigServices = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -182,7 +182,7 @@ services:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to configs in services", () => {
|
test("Add suffix to configs in services", () => {
|
||||||
const composeData = parse(composeFileConfigServices) as ComposeSpecification;
|
const composeData = load(composeFileConfigServices) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -43,7 +43,7 @@ configs:
|
|||||||
file: ./db-config.yml
|
file: ./db-config.yml
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileCombinedConfigs = parse(`
|
const expectedComposeFileCombinedConfigs = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -77,7 +77,7 @@ configs:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all configs in root and services", () => {
|
test("Add suffix to all configs in root and services", () => {
|
||||||
const composeData = parse(composeFileCombinedConfigs) as ComposeSpecification;
|
const composeData = load(composeFileCombinedConfigs) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ configs:
|
|||||||
file: ./db-config.yml
|
file: ./db-config.yml
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileWithEnvAndExternal = parse(`
|
const expectedComposeFileWithEnvAndExternal = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -159,7 +159,7 @@ configs:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to configs with environment and external", () => {
|
test("Add suffix to configs with environment and external", () => {
|
||||||
const composeData = parse(
|
const composeData = load(
|
||||||
composeFileWithEnvAndExternal,
|
composeFileWithEnvAndExternal,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ configs:
|
|||||||
file: ./db-config.yml
|
file: ./db-config.yml
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileWithTemplateDriverAndLabels = parse(`
|
const expectedComposeFileWithTemplateDriverAndLabels = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -231,7 +231,7 @@ configs:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to configs with template driver and labels", () => {
|
test("Add suffix to configs with template driver and labels", () => {
|
||||||
const composeData = parse(
|
const composeData = load(
|
||||||
composeFileWithTemplateDriverAndLabels,
|
composeFileWithTemplateDriverAndLabels,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
|
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
import type { Domain } from "@dokploy/server";
|
|
||||||
import { createDomainLabels } from "@dokploy/server";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { parse, stringify } from "yaml";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression tests for Traefik Host rule label format.
|
|
||||||
*
|
|
||||||
* These tests verify that the Host rule is generated with the correct format:
|
|
||||||
* - Host(`domain.com`) - with opening and closing parentheses
|
|
||||||
* - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
|
|
||||||
*
|
|
||||||
* Issue: https://github.com/Dokploy/dokploy/issues/3161
|
|
||||||
* The bug caused Host rules to be malformed as Host`domain.com`)
|
|
||||||
* (missing opening parenthesis) which broke all domain routing.
|
|
||||||
*/
|
|
||||||
describe("Host rule format regression tests", () => {
|
|
||||||
const baseDomain: Domain = {
|
|
||||||
host: "example.com",
|
|
||||||
port: 8080,
|
|
||||||
https: false,
|
|
||||||
uniqueConfigKey: 1,
|
|
||||||
customCertResolver: null,
|
|
||||||
certificateType: "none",
|
|
||||||
applicationId: "",
|
|
||||||
composeId: "",
|
|
||||||
domainType: "compose",
|
|
||||||
serviceName: "test-app",
|
|
||||||
domainId: "",
|
|
||||||
path: "/",
|
|
||||||
createdAt: "",
|
|
||||||
previewDeploymentId: "",
|
|
||||||
internalPath: "/",
|
|
||||||
stripPath: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("Host rule format validation", () => {
|
|
||||||
it("should generate Host rule with correct parentheses format", async () => {
|
|
||||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
|
||||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
|
||||||
|
|
||||||
expect(ruleLabel).toBeDefined();
|
|
||||||
// Verify exact format: Host(`domain`)
|
|
||||||
expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
|
|
||||||
// Ensure opening parenthesis is present after Host
|
|
||||||
expect(ruleLabel).toContain("Host(`example.com`)");
|
|
||||||
// Ensure it does NOT have the malformed format
|
|
||||||
expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should generate PathPrefix with correct parentheses format", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
"test-app",
|
|
||||||
{ ...baseDomain, path: "/api" },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
|
||||||
|
|
||||||
expect(ruleLabel).toBeDefined();
|
|
||||||
// Verify PathPrefix format
|
|
||||||
expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
|
|
||||||
expect(ruleLabel).toContain("PathPrefix(`/api`)");
|
|
||||||
// Ensure opening parenthesis is present
|
|
||||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should generate combined Host and PathPrefix with correct format", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
"test-app",
|
|
||||||
{ ...baseDomain, path: "/api/v1" },
|
|
||||||
"websecure",
|
|
||||||
);
|
|
||||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
|
||||||
|
|
||||||
expect(ruleLabel).toBeDefined();
|
|
||||||
expect(ruleLabel).toBe(
|
|
||||||
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("YAML serialization preserves Host rule format", () => {
|
|
||||||
it("should preserve Host rule format through YAML stringify/parse", async () => {
|
|
||||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
|
||||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
|
||||||
|
|
||||||
// Simulate compose file structure
|
|
||||||
const composeSpec = {
|
|
||||||
services: {
|
|
||||||
myapp: {
|
|
||||||
image: "nginx",
|
|
||||||
labels: labels,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stringify to YAML
|
|
||||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
|
||||||
|
|
||||||
// Parse back
|
|
||||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
|
||||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
|
||||||
l.includes(".rule="),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify format is preserved
|
|
||||||
expect(parsedRuleLabel).toBe(ruleLabel);
|
|
||||||
expect(parsedRuleLabel).toContain("Host(`example.com`)");
|
|
||||||
expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should preserve complex rule format through YAML serialization", async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
"test-app",
|
|
||||||
{ ...baseDomain, path: "/api", https: true },
|
|
||||||
"websecure",
|
|
||||||
);
|
|
||||||
|
|
||||||
const composeSpec = {
|
|
||||||
services: {
|
|
||||||
myapp: {
|
|
||||||
labels: labels,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
|
||||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
|
||||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
|
||||||
l.includes(".rule="),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(parsedRuleLabel).toContain(
|
|
||||||
"Host(`example.com`) && PathPrefix(`/api`)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Edge cases for domain names", () => {
|
|
||||||
const domainCases = [
|
|
||||||
{ name: "simple domain", host: "example.com" },
|
|
||||||
{ name: "subdomain", host: "app.example.com" },
|
|
||||||
{ name: "deep subdomain", host: "api.v1.app.example.com" },
|
|
||||||
{ name: "numeric domain", host: "123.example.com" },
|
|
||||||
{ name: "hyphenated domain", host: "my-app.example-host.com" },
|
|
||||||
{ name: "localhost", host: "localhost" },
|
|
||||||
{ name: "IP address style", host: "192.168.1.100" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { name, host } of domainCases) {
|
|
||||||
it(`should generate correct Host rule for ${name}: ${host}`, async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
"test-app",
|
|
||||||
{ ...baseDomain, host },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
|
||||||
|
|
||||||
expect(ruleLabel).toBeDefined();
|
|
||||||
expect(ruleLabel).toContain(`Host(\`${host}\`)`);
|
|
||||||
// Verify parenthesis is present
|
|
||||||
expect(ruleLabel).toMatch(
|
|
||||||
new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Multiple domains scenario", () => {
|
|
||||||
it("should generate correct format for both web and websecure entrypoints", async () => {
|
|
||||||
const webLabels = await createDomainLabels("test-app", baseDomain, "web");
|
|
||||||
const websecureLabels = await createDomainLabels(
|
|
||||||
"test-app",
|
|
||||||
baseDomain,
|
|
||||||
"websecure",
|
|
||||||
);
|
|
||||||
|
|
||||||
const webRule = webLabels.find((l) => l.includes(".rule="));
|
|
||||||
const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
|
|
||||||
|
|
||||||
// Both should have correct format
|
|
||||||
expect(webRule).toContain("Host(`example.com`)");
|
|
||||||
expect(websecureRule).toContain("Host(`example.com`)");
|
|
||||||
|
|
||||||
// Neither should have malformed format
|
|
||||||
expect(webRule).not.toMatch(/Host`[^`]+`\)/);
|
|
||||||
expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Special characters in paths", () => {
|
|
||||||
const pathCases = [
|
|
||||||
{ name: "simple path", path: "/api" },
|
|
||||||
{ name: "nested path", path: "/api/v1/users" },
|
|
||||||
{ name: "path with hyphen", path: "/api-v1" },
|
|
||||||
{ name: "path with underscore", path: "/api_v1" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { name, path } of pathCases) {
|
|
||||||
it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
|
|
||||||
const labels = await createDomainLabels(
|
|
||||||
"test-app",
|
|
||||||
{ ...baseDomain, path },
|
|
||||||
"web",
|
|
||||||
);
|
|
||||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
|
||||||
|
|
||||||
expect(ruleLabel).toBeDefined();
|
|
||||||
expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
|
|
||||||
// Verify parenthesis is present
|
|
||||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -35,7 +35,7 @@ test("Generate random hash with 8 characters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Add suffix to networks root property", () => {
|
test("Add suffix to networks root property", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to advanced networks root property (2 TRY)", () => {
|
test("Add suffix to advanced networks root property (2 TRY)", () => {
|
||||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
const composeData = load(composeFile2) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks with external properties", () => {
|
test("Add suffix to networks with external properties", () => {
|
||||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
const composeData = load(composeFile3) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks with IPAM configurations", () => {
|
test("Add suffix to networks with IPAM configurations", () => {
|
||||||
const composeData = parse(composeFile4) as ComposeSpecification;
|
const composeData = load(composeFile4) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks with custom options", () => {
|
test("Add suffix to networks with custom options", () => {
|
||||||
const composeData = parse(composeFile5) as ComposeSpecification;
|
const composeData = load(composeFile5) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -264,7 +264,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks with static suffix", () => {
|
test("Add suffix to networks with static suffix", () => {
|
||||||
const composeData = parse(composeFile6) as ComposeSpecification;
|
const composeData = load(composeFile6) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ test("Add suffix to networks with static suffix", () => {
|
|||||||
}
|
}
|
||||||
const networks = addSuffixToNetworksRoot(composeData.networks, suffix);
|
const networks = addSuffixToNetworksRoot(composeData.networks, suffix);
|
||||||
|
|
||||||
const expectedComposeData = parse(
|
const expectedComposeData = load(
|
||||||
expectedComposeFile6,
|
expectedComposeFile6,
|
||||||
) as ComposeSpecification;
|
) as ComposeSpecification;
|
||||||
expect(networks).toStrictEqual(expectedComposeData.networks);
|
expect(networks).toStrictEqual(expectedComposeData.networks);
|
||||||
@@ -293,7 +293,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shoudn't add suffix to dokploy-network", () => {
|
test("It shoudn't add suffix to dokploy-network", () => {
|
||||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
const composeData = load(composeFile7) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
addSuffixToServiceNetworks,
|
addSuffixToServiceNetworks,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks in services", () => {
|
test("Add suffix to networks in services", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks in services with aliases", () => {
|
test("Add suffix to networks in services with aliases", () => {
|
||||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
const composeData = load(composeFile2) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks in services (Object with simple networks)", () => {
|
test("Add suffix to networks in services (Object with simple networks)", () => {
|
||||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
const composeData = load(composeFile3) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks in services (combined case)", () => {
|
test("Add suffix to networks in services (combined case)", () => {
|
||||||
const composeData = parse(composeFileCombined) as ComposeSpecification;
|
const composeData = load(composeFileCombined) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ services:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shoudn't add suffix to dokploy-network in services", () => {
|
test("It shoudn't add suffix to dokploy-network in services", () => {
|
||||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
const composeData = load(composeFile7) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ services:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
|
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
|
||||||
const composeData = parse(composeFile8) as ComposeSpecification;
|
const composeData = load(composeFile8) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
addSuffixToServiceNetworks,
|
addSuffixToServiceNetworks,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFileCombined = `
|
const composeFileCombined = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -39,7 +39,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to networks in services and root (combined case)", () => {
|
test("Add suffix to networks in services and root (combined case)", () => {
|
||||||
const composeData = parse(composeFileCombined) as ComposeSpecification;
|
const composeData = load(composeFileCombined) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ test("Add suffix to networks in services and root (combined case)", () => {
|
|||||||
expect(redisNetworks).not.toHaveProperty("backend");
|
expect(redisNetworks).not.toHaveProperty("backend");
|
||||||
});
|
});
|
||||||
|
|
||||||
const expectedComposeFile = parse(`
|
const expectedComposeFile = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -120,7 +120,7 @@ networks:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
test("Add suffix to networks in compose file", () => {
|
test("Add suffix to networks in compose file", () => {
|
||||||
const composeData = parse(composeFileCombined) as ComposeSpecification;
|
const composeData = load(composeFileCombined) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
if (!composeData?.networks) {
|
if (!composeData?.networks) {
|
||||||
@@ -156,7 +156,7 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile2 = parse(`
|
const expectedComposeFile2 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -182,7 +182,7 @@ networks:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
test("Add suffix to networks in compose file with external and internal networks", () => {
|
test("Add suffix to networks in compose file with external and internal networks", () => {
|
||||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
const composeData = load(composeFile2) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
||||||
@@ -218,7 +218,7 @@ networks:
|
|||||||
com.docker.network.bridge.enable_icc: "true"
|
com.docker.network.bridge.enable_icc: "true"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile3 = parse(`
|
const expectedComposeFile3 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -247,7 +247,7 @@ networks:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
test("Add suffix to networks in compose file with multiple services and complex network configurations", () => {
|
test("Add suffix to networks in compose file with multiple services and complex network configurations", () => {
|
||||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
const composeData = load(composeFile3) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
||||||
@@ -289,7 +289,7 @@ networks:
|
|||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile4 = parse(`
|
const expectedComposeFile4 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -326,7 +326,7 @@ networks:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
test("Expect don't add suffix to dokploy-network in compose file with multiple services and complex network configurations", () => {
|
test("Expect don't add suffix to dokploy-network in compose file with multiple services and complex network configurations", () => {
|
||||||
const composeData = parse(composeFile4) as ComposeSpecification;
|
const composeData = load(composeFile4) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -23,7 +23,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in root property", () => {
|
test("Add suffix to secrets in root property", () => {
|
||||||
const composeData = parse(composeFileSecretsRoot) as ComposeSpecification;
|
const composeData = load(composeFileSecretsRoot) as ComposeSpecification;
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData?.secrets) {
|
if (!composeData?.secrets) {
|
||||||
@@ -52,7 +52,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in root property (Test 1)", () => {
|
test("Add suffix to secrets in root property (Test 1)", () => {
|
||||||
const composeData = parse(composeFileSecretsRoot1) as ComposeSpecification;
|
const composeData = load(composeFileSecretsRoot1) as ComposeSpecification;
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData?.secrets) {
|
if (!composeData?.secrets) {
|
||||||
@@ -84,7 +84,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in root property (Test 2)", () => {
|
test("Add suffix to secrets in root property (Test 2)", () => {
|
||||||
const composeData = parse(composeFileSecretsRoot2) as ComposeSpecification;
|
const composeData = load(composeFileSecretsRoot2) as ComposeSpecification;
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData?.secrets) {
|
if (!composeData?.secrets) {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
addSuffixToSecretsInServices,
|
addSuffixToSecretsInServices,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFileSecretsServices = `
|
const composeFileSecretsServices = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -21,7 +21,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in services", () => {
|
test("Add suffix to secrets in services", () => {
|
||||||
const composeData = parse(composeFileSecretsServices) as ComposeSpecification;
|
const composeData = load(composeFileSecretsServices) as ComposeSpecification;
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData.services) {
|
if (!composeData.services) {
|
||||||
@@ -54,9 +54,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in services (Test 1)", () => {
|
test("Add suffix to secrets in services (Test 1)", () => {
|
||||||
const composeData = parse(
|
const composeData = load(composeFileSecretsServices1) as ComposeSpecification;
|
||||||
composeFileSecretsServices1,
|
|
||||||
) as ComposeSpecification;
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData.services) {
|
if (!composeData.services) {
|
||||||
@@ -95,9 +93,7 @@ secrets:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to secrets in services (Test 2)", () => {
|
test("Add suffix to secrets in services (Test 2)", () => {
|
||||||
const composeData = parse(
|
const composeData = load(composeFileSecretsServices2) as ComposeSpecification;
|
||||||
composeFileSecretsServices2,
|
|
||||||
) as ComposeSpecification;
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
if (!composeData.services) {
|
if (!composeData.services) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToAllSecrets } from "@dokploy/server";
|
import { addSuffixToAllSecrets } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFileCombinedSecrets = `
|
const composeFileCombinedSecrets = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -25,7 +25,7 @@ secrets:
|
|||||||
file: ./app_secret.txt
|
file: ./app_secret.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileCombinedSecrets = parse(`
|
const expectedComposeFileCombinedSecrets = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -48,7 +48,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all secrets", () => {
|
test("Add suffix to all secrets", () => {
|
||||||
const composeData = parse(composeFileCombinedSecrets) as ComposeSpecification;
|
const composeData = load(composeFileCombinedSecrets) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
||||||
@@ -77,7 +77,7 @@ secrets:
|
|||||||
file: ./cache_secret.txt
|
file: ./cache_secret.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileCombinedSecrets3 = parse(`
|
const expectedComposeFileCombinedSecrets3 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -99,9 +99,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all secrets (3rd Case)", () => {
|
test("Add suffix to all secrets (3rd Case)", () => {
|
||||||
const composeData = parse(
|
const composeData = load(composeFileCombinedSecrets3) as ComposeSpecification;
|
||||||
composeFileCombinedSecrets3,
|
|
||||||
) as ComposeSpecification;
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
||||||
@@ -130,7 +128,7 @@ secrets:
|
|||||||
file: ./db_password.txt
|
file: ./db_password.txt
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileCombinedSecrets4 = parse(`
|
const expectedComposeFileCombinedSecrets4 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -152,9 +150,7 @@ secrets:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all secrets (4th Case)", () => {
|
test("Add suffix to all secrets (4th Case)", () => {
|
||||||
const composeData = parse(
|
const composeData = load(composeFileCombinedSecrets4) as ComposeSpecification;
|
||||||
composeFileCombinedSecrets4,
|
|
||||||
) as ComposeSpecification;
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -27,7 +27,7 @@ test("Generate random hash with 8 characters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Add suffix to service names with container_name in compose file", () => {
|
test("Add suffix to service names with container_name in compose file", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -32,7 +32,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with depends_on (array) in compose file", () => {
|
test("Add suffix to service names with depends_on (array) in compose file", () => {
|
||||||
const composeData = parse(composeFile4) as ComposeSpecification;
|
const composeData = load(composeFile4) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with depends_on (object) in compose file", () => {
|
test("Add suffix to service names with depends_on (object) in compose file", () => {
|
||||||
const composeData = parse(composeFile5) as ComposeSpecification;
|
const composeData = load(composeFile5) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -30,7 +30,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with extends (string) in compose file", () => {
|
test("Add suffix to service names with extends (string) in compose file", () => {
|
||||||
const composeData = parse(composeFile6) as ComposeSpecification;
|
const composeData = load(composeFile6) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with extends (object) in compose file", () => {
|
test("Add suffix to service names with extends (object) in compose file", () => {
|
||||||
const composeData = parse(composeFile7) as ComposeSpecification;
|
const composeData = load(composeFile7) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -31,7 +31,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with links in compose file", () => {
|
test("Add suffix to service names with links in compose file", () => {
|
||||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
const composeData = load(composeFile2) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -26,7 +26,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names in compose file", () => {
|
test("Add suffix to service names in compose file", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
addSuffixToAllServiceNames,
|
addSuffixToAllServiceNames,
|
||||||
addSuffixToServiceNames,
|
addSuffixToServiceNames,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFileCombinedAllCases = `
|
const composeFileCombinedAllCases = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -38,7 +38,7 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile = parse(`
|
const expectedComposeFile = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -71,9 +71,7 @@ networks:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
test("Add suffix to all service names in compose file", () => {
|
test("Add suffix to all service names in compose file", () => {
|
||||||
const composeData = parse(
|
const composeData = load(composeFileCombinedAllCases) as ComposeSpecification;
|
||||||
composeFileCombinedAllCases,
|
|
||||||
) as ComposeSpecification;
|
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -133,7 +131,7 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile1 = parse(`
|
const expectedComposeFile1 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -178,7 +176,7 @@ networks:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all service names in compose file 1", () => {
|
test("Add suffix to all service names in compose file 1", () => {
|
||||||
const composeData = parse(composeFile1) as ComposeSpecification;
|
const composeData = load(composeFile1) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
||||||
@@ -229,7 +227,7 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile2 = parse(`
|
const expectedComposeFile2 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -273,7 +271,7 @@ networks:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all service names in compose file 2", () => {
|
test("Add suffix to all service names in compose file 2", () => {
|
||||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
const composeData = load(composeFile2) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
||||||
@@ -324,7 +322,7 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFile3 = parse(`
|
const expectedComposeFile3 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -368,7 +366,7 @@ networks:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to all service names in compose file 3", () => {
|
test("Add suffix to all service names in compose file 3", () => {
|
||||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
const composeData = load(composeFile3) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -35,7 +35,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to service names with volumes_from in compose file", () => {
|
test("Add suffix to service names with volumes_from in compose file", () => {
|
||||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
const composeData = load(composeFile3) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
addSuffixToVolumesRoot,
|
addSuffixToVolumesRoot,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
services:
|
services:
|
||||||
@@ -70,7 +70,7 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerCompose = parse(`
|
const expectedDockerCompose = load(`
|
||||||
services:
|
services:
|
||||||
mail:
|
mail:
|
||||||
image: bytemark/smtp
|
image: bytemark/smtp
|
||||||
@@ -143,7 +143,7 @@ test("Generate random hash with 8 characters", () => {
|
|||||||
// Docker compose needs unique names for services, volumes, networks and containers
|
// Docker compose needs unique names for services, volumes, networks and containers
|
||||||
// So base on a input which is a dockercompose file, it should replace the name with a hash and return a new dockercompose file
|
// So base on a input which is a dockercompose file, it should replace the name with a hash and return a new dockercompose file
|
||||||
test("Add suffix to volumes root property", () => {
|
test("Add suffix to volumes root property", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ test("Add suffix to volumes root property", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Expect to change the suffix in all the possible places", () => {
|
test("Expect to change the suffix in all the possible places", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
@@ -195,7 +195,7 @@ volumes:
|
|||||||
mongo-data:
|
mongo-data:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerCompose2 = parse(`
|
const expectedDockerCompose2 = load(`
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
@@ -218,7 +218,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Expect to change the suffix in all the possible places (2 Try)", () => {
|
test("Expect to change the suffix in all the possible places (2 Try)", () => {
|
||||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
const composeData = load(composeFile2) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
@@ -248,7 +248,7 @@ volumes:
|
|||||||
mongo-data:
|
mongo-data:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerCompose3 = parse(`
|
const expectedDockerCompose3 = load(`
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
@@ -271,7 +271,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Expect to change the suffix in all the possible places (3 Try)", () => {
|
test("Expect to change the suffix in all the possible places (3 Try)", () => {
|
||||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
const composeData = load(composeFile3) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
@@ -645,7 +645,7 @@ volumes:
|
|||||||
db-config:
|
db-config:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerComposeComplex = parse(`
|
const expectedDockerComposeComplex = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
services:
|
services:
|
||||||
studio:
|
studio:
|
||||||
@@ -1012,7 +1012,7 @@ volumes:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
test("Expect to change the suffix in all the possible places (4 Try)", () => {
|
test("Expect to change the suffix in all the possible places (4 Try)", () => {
|
||||||
const composeData = parse(composeFileComplex) as ComposeSpecification;
|
const composeData = load(composeFileComplex) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
@@ -1065,7 +1065,7 @@ volumes:
|
|||||||
db-data:
|
db-data:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerComposeExample1 = parse(`
|
const expectedDockerComposeExample1 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
@@ -1111,7 +1111,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Expect to change the suffix in all the possible places (5 Try)", () => {
|
test("Expect to change the suffix in all the possible places (5 Try)", () => {
|
||||||
const composeData = parse(composeFileExample1) as ComposeSpecification;
|
const composeData = load(composeFileExample1) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
@@ -1143,7 +1143,7 @@ volumes:
|
|||||||
backrest-cache:
|
backrest-cache:
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedDockerComposeBackrest = parse(`
|
const expectedDockerComposeBackrest = load(`
|
||||||
services:
|
services:
|
||||||
backrest:
|
backrest:
|
||||||
image: garethgeorge/backrest:v1.7.3
|
image: garethgeorge/backrest:v1.7.3
|
||||||
@@ -1168,7 +1168,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Should handle volume paths with subdirectories correctly", () => {
|
test("Should handle volume paths with subdirectories correctly", () => {
|
||||||
const composeData = parse(composeFileBackrest) as ComposeSpecification;
|
const composeData = load(composeFileBackrest) as ComposeSpecification;
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server";
|
import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFile = `
|
const composeFile = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -29,7 +29,7 @@ test("Generate random hash with 8 characters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Add suffix to volumes in root property", () => {
|
test("Add suffix to volumes in root property", () => {
|
||||||
const composeData = parse(composeFile) as ComposeSpecification;
|
const composeData = load(composeFile) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to volumes in root property (Case 2)", () => {
|
test("Add suffix to volumes in root property (Case 2)", () => {
|
||||||
const composeData = parse(composeFile2) as ComposeSpecification;
|
const composeData = load(composeFile2) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ networks:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to volumes in root property (Case 3)", () => {
|
test("Add suffix to volumes in root property (Case 3)", () => {
|
||||||
const composeData = parse(composeFile3) as ComposeSpecification;
|
const composeData = load(composeFile3) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ volumes:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// Expected compose file con el prefijo `testhash`
|
// Expected compose file con el prefijo `testhash`
|
||||||
const expectedComposeFile4 = parse(`
|
const expectedComposeFile4 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -179,7 +179,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to volumes in root property", () => {
|
test("Add suffix to volumes in root property", () => {
|
||||||
const composeData = parse(composeFile4) as ComposeSpecification;
|
const composeData = load(composeFile4) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
addSuffixToVolumesInServices,
|
addSuffixToVolumesInServices,
|
||||||
generateRandomHash,
|
generateRandomHash,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
test("Generate random hash with 8 characters", () => {
|
test("Generate random hash with 8 characters", () => {
|
||||||
const hash = generateRandomHash();
|
const hash = generateRandomHash();
|
||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to volumes declared directly in services", () => {
|
test("Add suffix to volumes declared directly in services", () => {
|
||||||
const composeData = parse(composeFile1) as ComposeSpecification;
|
const composeData = load(composeFile1) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ volumes:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
test("Add suffix to volumes declared directly in services (Case 2)", () => {
|
test("Add suffix to volumes declared directly in services (Case 2)", () => {
|
||||||
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
|
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = generateRandomHash();
|
const suffix = generateRandomHash();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComposeSpecification } from "@dokploy/server";
|
import type { ComposeSpecification } from "@dokploy/server";
|
||||||
import { addSuffixToAllVolumes } from "@dokploy/server";
|
import { addSuffixToAllVolumes } from "@dokploy/server";
|
||||||
|
import { load } from "js-yaml";
|
||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
|
||||||
|
|
||||||
const composeFileTypeVolume = `
|
const composeFileTypeVolume = `
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
@@ -23,7 +23,7 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileTypeVolume = parse(`
|
const expectedComposeFileTypeVolume = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -44,7 +44,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to volumes with type: volume in services", () => {
|
test("Add suffix to volumes with type: volume in services", () => {
|
||||||
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
|
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileTypeVolume1 = parse(`
|
const expectedComposeFileTypeVolume1 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -93,7 +93,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to mixed volumes in services", () => {
|
test("Add suffix to mixed volumes in services", () => {
|
||||||
const composeData = parse(composeFileTypeVolume1) as ComposeSpecification;
|
const composeData = load(composeFileTypeVolume1) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ volumes:
|
|||||||
device: /path/to/app/logs
|
device: /path/to/app/logs
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileTypeVolume2 = parse(`
|
const expectedComposeFileTypeVolume2 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -154,7 +154,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to complex volume configurations in services", () => {
|
test("Add suffix to complex volume configurations in services", () => {
|
||||||
const composeData = parse(composeFileTypeVolume2) as ComposeSpecification;
|
const composeData = load(composeFileTypeVolume2) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ volumes:
|
|||||||
device: /path/to/shared/logs
|
device: /path/to/shared/logs
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const expectedComposeFileTypeVolume3 = parse(`
|
const expectedComposeFileTypeVolume3 = load(`
|
||||||
version: "3.8"
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -273,7 +273,7 @@ volumes:
|
|||||||
`) as ComposeSpecification;
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
test("Add suffix to complex nested volumes configuration in services", () => {
|
test("Add suffix to complex nested volumes configuration in services", () => {
|
||||||
const composeData = parse(composeFileTypeVolume3) as ComposeSpecification;
|
const composeData = load(composeFileTypeVolume3) as ComposeSpecification;
|
||||||
|
|
||||||
const suffix = "testhash";
|
const suffix = "testhash";
|
||||||
|
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
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).mockResolvedValue(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).mockResolvedValue(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).mockResolvedValue(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).mockResolvedValue(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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,479 +0,0 @@
|
|||||||
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,10 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
|
||||||
extractCommitMessage,
|
|
||||||
extractImageName,
|
|
||||||
extractImageTag,
|
|
||||||
extractImageTagFromRequest,
|
|
||||||
} from "@/pages/api/deploy/[refreshToken]";
|
|
||||||
|
|
||||||
describe("GitHub Webhook Skip CI", () => {
|
describe("GitHub Webhook Skip CI", () => {
|
||||||
const mockGithubHeaders = {
|
const mockGithubHeaders = {
|
||||||
@@ -101,308 +96,3 @@ 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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -25,16 +25,11 @@ if (typeof window === "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
railpackVersion: "0.15.4",
|
railpackVersion: "0.2.2",
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
createEnvFile: true,
|
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaBranch: "",
|
giteaBranch: "",
|
||||||
buildServerId: "",
|
|
||||||
buildRegistryId: "",
|
|
||||||
buildRegistry: null,
|
|
||||||
args: [],
|
|
||||||
giteaBuildPath: "",
|
giteaBuildPath: "",
|
||||||
previewRequireCollaboratorPermissions: false,
|
previewRequireCollaboratorPermissions: false,
|
||||||
giteaId: "",
|
giteaId: "",
|
||||||
@@ -42,22 +37,17 @@ const baseApp: ApplicationNested = {
|
|||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
rollbackRegistryId: "",
|
|
||||||
rollbackRegistry: null,
|
|
||||||
deployments: [],
|
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
triggerType: "push",
|
triggerType: "push",
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
endpointSpecSwarm: null,
|
|
||||||
serverId: "",
|
serverId: "",
|
||||||
registryUrl: "",
|
registryUrl: "",
|
||||||
branch: null,
|
branch: null,
|
||||||
dockerBuildStage: "",
|
dockerBuildStage: "",
|
||||||
isPreviewDeploymentsActive: false,
|
isPreviewDeploymentsActive: false,
|
||||||
previewBuildArgs: null,
|
previewBuildArgs: null,
|
||||||
previewBuildSecrets: null,
|
|
||||||
previewCertificateType: "none",
|
previewCertificateType: "none",
|
||||||
previewCustomCertResolver: null,
|
previewCustomCertResolver: null,
|
||||||
previewEnv: null,
|
previewEnv: null,
|
||||||
@@ -68,7 +58,6 @@ const baseApp: ApplicationNested = {
|
|||||||
previewWildcard: "",
|
previewWildcard: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
isDefault: false,
|
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
@@ -84,7 +73,6 @@ const baseApp: ApplicationNested = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
buildArgs: null,
|
buildArgs: null,
|
||||||
buildSecrets: null,
|
|
||||||
buildPath: "/",
|
buildPath: "/",
|
||||||
gitlabPathNamespace: "",
|
gitlabPathNamespace: "",
|
||||||
buildType: "nixpacks",
|
buildType: "nixpacks",
|
||||||
@@ -145,7 +133,6 @@ const baseApp: ApplicationNested = {
|
|||||||
username: null,
|
username: null,
|
||||||
dockerContextPath: null,
|
dockerContextPath: null,
|
||||||
rollbackActive: false,
|
rollbackActive: false,
|
||||||
stopGracePeriodSwarm: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("unzipDrop using real zip files", () => {
|
describe("unzipDrop using real zip files", () => {
|
||||||
|
|||||||
311
apps/dokploy/__test__/env/environment.test.ts
vendored
311
apps/dokploy/__test__/env/environment.test.ts
vendored
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
||||||
prepareEnvironmentVariables,
|
|
||||||
prepareEnvironmentVariablesForShell,
|
|
||||||
} from "@dokploy/server/index";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const projectEnv = `
|
const projectEnv = `
|
||||||
@@ -335,310 +332,4 @@ 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é");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,22 +54,4 @@ describe("processLogs", () => {
|
|||||||
const result = parseRawConfig(entryWithWhitespace);
|
const result = parseRawConfig(entryWithWhitespace);
|
||||||
expect(result.data).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter out Dokploy dashboard requests", () => {
|
|
||||||
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
|
|
||||||
|
|
||||||
// Test with only Dokploy dashboard entry - should be filtered out
|
|
||||||
const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry);
|
|
||||||
expect(resultOnlyDokploy.data).toHaveLength(0);
|
|
||||||
expect(resultOnlyDokploy.totalCount).toBe(0);
|
|
||||||
|
|
||||||
// Test with mixed entries - Dokploy should be filtered, others should remain
|
|
||||||
const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`;
|
|
||||||
const resultMixed = parseRawConfig(mixedEntries);
|
|
||||||
expect(resultMixed.data).toHaveLength(1);
|
|
||||||
expect(resultMixed.totalCount).toBe(1);
|
|
||||||
expect(resultMixed.data[0]?.ServiceName).not.toBe(
|
|
||||||
"dokploy-service-app@file",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
|
||||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
type MockCreateServiceOptions = {
|
|
||||||
TaskTemplate?: {
|
|
||||||
ContainerSpec?: {
|
|
||||||
StopGracePeriod?: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
|
|
||||||
vi.hoisted(() => {
|
|
||||||
const inspect = vi.fn<[], Promise<never>>();
|
|
||||||
const getService = vi.fn(() => ({ inspect }));
|
|
||||||
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
|
|
||||||
async () => undefined,
|
|
||||||
);
|
|
||||||
const getRemoteDocker = vi.fn(async () => ({
|
|
||||||
getService,
|
|
||||||
createService,
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
inspectMock: inspect,
|
|
||||||
getServiceMock: getService,
|
|
||||||
createServiceMock: createService,
|
|
||||||
getRemoteDockerMock: getRemoteDocker,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
|
||||||
getRemoteDocker: getRemoteDockerMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const createApplication = (
|
|
||||||
overrides: Partial<ApplicationNested> = {},
|
|
||||||
): ApplicationNested =>
|
|
||||||
({
|
|
||||||
appName: "test-app",
|
|
||||||
buildType: "dockerfile",
|
|
||||||
env: null,
|
|
||||||
mounts: [],
|
|
||||||
cpuLimit: null,
|
|
||||||
memoryLimit: null,
|
|
||||||
memoryReservation: null,
|
|
||||||
cpuReservation: null,
|
|
||||||
command: null,
|
|
||||||
ports: [],
|
|
||||||
sourceType: "docker",
|
|
||||||
dockerImage: "example:latest",
|
|
||||||
registry: null,
|
|
||||||
environment: {
|
|
||||||
project: { env: null },
|
|
||||||
env: null,
|
|
||||||
},
|
|
||||||
replicas: 1,
|
|
||||||
stopGracePeriodSwarm: 0n,
|
|
||||||
serverId: "server-id",
|
|
||||||
...overrides,
|
|
||||||
}) as unknown as ApplicationNested;
|
|
||||||
|
|
||||||
describe("mechanizeDockerContainer", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
inspectMock.mockReset();
|
|
||||||
inspectMock.mockRejectedValue(new Error("service not found"));
|
|
||||||
getServiceMock.mockClear();
|
|
||||||
createServiceMock.mockClear();
|
|
||||||
getRemoteDockerMock.mockClear();
|
|
||||||
getRemoteDockerMock.mockResolvedValue({
|
|
||||||
getService: getServiceMock,
|
|
||||||
createService: createServiceMock,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
|
|
||||||
const application = createApplication({ stopGracePeriodSwarm: 0n });
|
|
||||||
|
|
||||||
await mechanizeDockerContainer(application);
|
|
||||||
|
|
||||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
|
||||||
const call = createServiceMock.mock.calls[0];
|
|
||||||
if (!call) {
|
|
||||||
throw new Error("createServiceMock should have been called once");
|
|
||||||
}
|
|
||||||
const [settings] = call;
|
|
||||||
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
|
|
||||||
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
|
|
||||||
"number",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
|
||||||
const application = createApplication({ stopGracePeriodSwarm: null });
|
|
||||||
|
|
||||||
await mechanizeDockerContainer(application);
|
|
||||||
|
|
||||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
|
||||||
const call = createServiceMock.mock.calls[0];
|
|
||||||
if (!call) {
|
|
||||||
throw new Error("createServiceMock should have been called once");
|
|
||||||
}
|
|
||||||
const [settings] = call;
|
|
||||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
|
|
||||||
"StopGracePeriod",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -161,50 +161,6 @@ describe("helpers functions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Empty string variables", () => {
|
|
||||||
it("should replace variables with empty string values correctly", () => {
|
|
||||||
const variables = {
|
|
||||||
smtp_username: "",
|
|
||||||
smtp_password: "",
|
|
||||||
non_empty: "value",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result1 = processValue("${smtp_username}", variables, mockSchema);
|
|
||||||
expect(result1).toBe("");
|
|
||||||
|
|
||||||
const result2 = processValue("${smtp_password}", variables, mockSchema);
|
|
||||||
expect(result2).toBe("");
|
|
||||||
|
|
||||||
const result3 = processValue("${non_empty}", variables, mockSchema);
|
|
||||||
expect(result3).toBe("value");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not replace undefined variables", () => {
|
|
||||||
const variables = {
|
|
||||||
defined_var: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = processValue("${undefined_var}", variables, mockSchema);
|
|
||||||
expect(result).toBe("${undefined_var}");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle mixed empty and non-empty variables in template", () => {
|
|
||||||
const variables = {
|
|
||||||
smtp_address: "smtp.example.com",
|
|
||||||
smtp_port: "2525",
|
|
||||||
smtp_username: "",
|
|
||||||
smtp_password: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const template =
|
|
||||||
"SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}";
|
|
||||||
const result = processValue(template, variables, mockSchema);
|
|
||||||
expect(result).toBe(
|
|
||||||
"SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("${jwt}", () => {
|
describe("${jwt}", () => {
|
||||||
it("should generate a JWT string", () => {
|
it("should generate a JWT string", () => {
|
||||||
const jwt = processValue("${jwt}", {}, mockSchema);
|
const jwt = processValue("${jwt}", {}, mockSchema);
|
||||||
@@ -272,58 +228,5 @@ describe("helpers functions", () => {
|
|||||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle JWT payload with newlines and whitespace by trimming them", () => {
|
|
||||||
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
|
|
||||||
const expiry = iat + 3600;
|
|
||||||
const payloadWithNewlines = `{
|
|
||||||
"role": "anon",
|
|
||||||
"iss": "supabase",
|
|
||||||
"exp": ${expiry}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
const jwt = processValue(
|
|
||||||
"${jwt:secret:payload}",
|
|
||||||
{
|
|
||||||
secret: "mysecret",
|
|
||||||
payload: payloadWithNewlines,
|
|
||||||
},
|
|
||||||
mockSchema,
|
|
||||||
);
|
|
||||||
expect(jwt).toMatch(jwtMatchExp);
|
|
||||||
const parts = jwt.split(".") as JWTParts;
|
|
||||||
jwtCheckHeader(parts[0]);
|
|
||||||
const decodedPayload = jwtBase64Decode(parts[1]);
|
|
||||||
expect(decodedPayload).toHaveProperty("role");
|
|
||||||
expect(decodedPayload.role).toEqual("anon");
|
|
||||||
expect(decodedPayload).toHaveProperty("iss");
|
|
||||||
expect(decodedPayload.iss).toEqual("supabase");
|
|
||||||
expect(decodedPayload).toHaveProperty("exp");
|
|
||||||
expect(decodedPayload.exp).toEqual(expiry);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle JWT payload with leading and trailing whitespace", () => {
|
|
||||||
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
|
|
||||||
const expiry = iat + 3600;
|
|
||||||
const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `;
|
|
||||||
const jwt = processValue(
|
|
||||||
"${jwt:secret:payload}",
|
|
||||||
{
|
|
||||||
secret: "mysecret",
|
|
||||||
payload: payloadWithWhitespace,
|
|
||||||
},
|
|
||||||
mockSchema,
|
|
||||||
);
|
|
||||||
expect(jwt).toMatch(jwtMatchExp);
|
|
||||||
const parts = jwt.split(".") as JWTParts;
|
|
||||||
jwtCheckHeader(parts[0]);
|
|
||||||
const decodedPayload = jwtBase64Decode(parts[1]);
|
|
||||||
expect(decodedPayload).toHaveProperty("role");
|
|
||||||
expect(decodedPayload.role).toEqual("service_role");
|
|
||||||
expect(decodedPayload).toHaveProperty("iss");
|
|
||||||
expect(decodedPayload.iss).toEqual("supabase");
|
|
||||||
expect(decodedPayload).toHaveProperty("exp");
|
|
||||||
expect(decodedPayload.exp).toEqual(expiry);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,27 +5,19 @@ vi.mock("node:fs", () => ({
|
|||||||
default: fs,
|
default: fs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import type { FileConfig } from "@dokploy/server";
|
import type { FileConfig, User } from "@dokploy/server";
|
||||||
import {
|
import {
|
||||||
createDefaultServerTraefikConfig,
|
createDefaultServerTraefikConfig,
|
||||||
loadOrCreateConfig,
|
loadOrCreateConfig,
|
||||||
updateServerTraefik,
|
updateServerTraefik,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import type { webServerSettings } from "@dokploy/server/db/schema";
|
|
||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
type WebServerSettings = typeof webServerSettings.$inferSelect;
|
const baseAdmin: User = {
|
||||||
|
|
||||||
const baseSettings: WebServerSettings = {
|
|
||||||
id: "",
|
|
||||||
https: false,
|
https: false,
|
||||||
certificateType: "none",
|
enablePaidFeatures: false,
|
||||||
host: null,
|
allowImpersonation: false,
|
||||||
serverIp: null,
|
role: "user",
|
||||||
letsEncryptEmail: null,
|
|
||||||
sshPrivateKey: null,
|
|
||||||
enableDockerCleanup: false,
|
|
||||||
logCleanupCron: null,
|
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
containers: {
|
containers: {
|
||||||
refreshRate: 20,
|
refreshRate: 20,
|
||||||
@@ -51,8 +43,30 @@ const baseSettings: WebServerSettings = {
|
|||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: false,
|
cleanupCacheOnCompose: false,
|
||||||
cleanupCacheOnPreviews: false,
|
cleanupCacheOnPreviews: false,
|
||||||
createdAt: null,
|
createdAt: new Date(),
|
||||||
|
serverIp: null,
|
||||||
|
certificateType: "none",
|
||||||
|
host: null,
|
||||||
|
letsEncryptEmail: null,
|
||||||
|
sshPrivateKey: null,
|
||||||
|
enableDockerCleanup: false,
|
||||||
|
logCleanupCron: null,
|
||||||
|
serversQuantity: 0,
|
||||||
|
stripeCustomerId: "",
|
||||||
|
stripeSubscriptionId: "",
|
||||||
|
banExpires: new Date(),
|
||||||
|
banned: true,
|
||||||
|
banReason: "",
|
||||||
|
email: "",
|
||||||
|
expirationDate: "",
|
||||||
|
id: "",
|
||||||
|
isRegistered: false,
|
||||||
|
name: "",
|
||||||
|
createdAt2: new Date().toISOString(),
|
||||||
|
emailVerified: false,
|
||||||
|
image: "",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
twoFactorEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -70,7 +84,7 @@ test("Should read the configuration file", () => {
|
|||||||
test("Should apply redirect-to-https", () => {
|
test("Should apply redirect-to-https", () => {
|
||||||
updateServerTraefik(
|
updateServerTraefik(
|
||||||
{
|
{
|
||||||
...baseSettings,
|
...baseAdmin,
|
||||||
https: true,
|
https: true,
|
||||||
certificateType: "letsencrypt",
|
certificateType: "letsencrypt",
|
||||||
},
|
},
|
||||||
@@ -85,7 +99,7 @@ test("Should apply redirect-to-https", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Should change only host when no certificate", () => {
|
test("Should change only host when no certificate", () => {
|
||||||
updateServerTraefik(baseSettings, "example.com");
|
updateServerTraefik(baseAdmin, "example.com");
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
@@ -95,7 +109,7 @@ test("Should change only host when no certificate", () => {
|
|||||||
test("Should not touch config without host", () => {
|
test("Should not touch config without host", () => {
|
||||||
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
|
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
updateServerTraefik(baseSettings, null);
|
updateServerTraefik(baseAdmin, null);
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
@@ -104,14 +118,11 @@ test("Should not touch config without host", () => {
|
|||||||
|
|
||||||
test("Should remove websecure if https rollback to http", () => {
|
test("Should remove websecure if https rollback to http", () => {
|
||||||
updateServerTraefik(
|
updateServerTraefik(
|
||||||
{ ...baseSettings, certificateType: "letsencrypt" },
|
{ ...baseAdmin, certificateType: "letsencrypt" },
|
||||||
"example.com",
|
"example.com",
|
||||||
);
|
);
|
||||||
|
|
||||||
updateServerTraefik(
|
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
|
||||||
{ ...baseSettings, certificateType: "none" },
|
|
||||||
"example.com",
|
|
||||||
);
|
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
|
|||||||
@@ -3,27 +3,18 @@ import { createRouterConfig } from "@dokploy/server";
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
railpackVersion: "0.15.4",
|
railpackVersion: "0.2.2",
|
||||||
rollbackActive: false,
|
rollbackActive: false,
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
createEnvFile: true,
|
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
giteaOwner: "",
|
giteaOwner: "",
|
||||||
giteaBranch: "",
|
giteaBranch: "",
|
||||||
buildServerId: "",
|
|
||||||
buildRegistryId: "",
|
|
||||||
buildRegistry: null,
|
|
||||||
giteaBuildPath: "",
|
giteaBuildPath: "",
|
||||||
giteaId: "",
|
giteaId: "",
|
||||||
args: [],
|
|
||||||
rollbackRegistryId: "",
|
|
||||||
rollbackRegistry: null,
|
|
||||||
deployments: [],
|
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
endpointSpecSwarm: null,
|
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
@@ -34,10 +25,8 @@ const baseApp: ApplicationNested = {
|
|||||||
registryUrl: "",
|
registryUrl: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
buildArgs: null,
|
buildArgs: null,
|
||||||
buildSecrets: null,
|
|
||||||
isPreviewDeploymentsActive: false,
|
isPreviewDeploymentsActive: false,
|
||||||
previewBuildArgs: null,
|
previewBuildArgs: null,
|
||||||
previewBuildSecrets: null,
|
|
||||||
triggerType: "push",
|
triggerType: "push",
|
||||||
previewCertificateType: "none",
|
previewCertificateType: "none",
|
||||||
previewEnv: null,
|
previewEnv: null,
|
||||||
@@ -50,7 +39,6 @@ const baseApp: ApplicationNested = {
|
|||||||
environmentId: "",
|
environmentId: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
isDefault: false,
|
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
@@ -123,7 +111,6 @@ const baseApp: ApplicationNested = {
|
|||||||
updateConfigSwarm: null,
|
updateConfigSwarm: null,
|
||||||
username: null,
|
username: null,
|
||||||
dockerContextPath: null,
|
dockerContextPath: null,
|
||||||
stopGracePeriodSwarm: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseDomain: Domain = {
|
const baseDomain: Domain = {
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ export default defineConfig({
|
|||||||
NODE: "test",
|
NODE: "test",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [tsconfigPaths()],
|
||||||
tsconfigPaths({
|
|
||||||
projects: [path.resolve(__dirname, "../tsconfig.json")],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@dokploy/server": path.resolve(
|
"@dokploy/server": path.resolve(
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -122,22 +121,6 @@ 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()
|
||||||
@@ -193,21 +176,10 @@ const addSwarmSettings = z.object({
|
|||||||
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
|
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
|
||||||
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
|
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
|
||||||
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
|
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
|
||||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
|
||||||
endpointSpecSwarm: createStringToJSONSchema(
|
|
||||||
EndpointSpecSwarmSchema,
|
|
||||||
).nullable(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
|
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
|
||||||
|
|
||||||
const hasStopGracePeriodSwarm = (
|
|
||||||
value: unknown,
|
|
||||||
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
"stopGracePeriodSwarm" in value;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
@@ -252,23 +224,12 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
modeSwarm: null,
|
modeSwarm: null,
|
||||||
labelsSwarm: null,
|
labelsSwarm: null,
|
||||||
networkSwarm: null,
|
networkSwarm: null,
|
||||||
stopGracePeriodSwarm: null,
|
|
||||||
endpointSpecSwarm: null,
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addSwarmSettings),
|
resolver: zodResolver(addSwarmSettings),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
const stopGracePeriodValue = hasStopGracePeriodSwarm(data)
|
|
||||||
? data.stopGracePeriodSwarm
|
|
||||||
: null;
|
|
||||||
const normalizedStopGracePeriod =
|
|
||||||
stopGracePeriodValue === null || stopGracePeriodValue === undefined
|
|
||||||
? null
|
|
||||||
: typeof stopGracePeriodValue === "bigint"
|
|
||||||
? stopGracePeriodValue
|
|
||||||
: BigInt(stopGracePeriodValue);
|
|
||||||
form.reset({
|
form.reset({
|
||||||
healthCheckSwarm: data.healthCheckSwarm
|
healthCheckSwarm: data.healthCheckSwarm
|
||||||
? JSON.stringify(data.healthCheckSwarm, null, 2)
|
? JSON.stringify(data.healthCheckSwarm, null, 2)
|
||||||
@@ -294,10 +255,6 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
networkSwarm: data.networkSwarm
|
networkSwarm: data.networkSwarm
|
||||||
? JSON.stringify(data.networkSwarm, null, 2)
|
? JSON.stringify(data.networkSwarm, null, 2)
|
||||||
: null,
|
: null,
|
||||||
stopGracePeriodSwarm: normalizedStopGracePeriod,
|
|
||||||
endpointSpecSwarm: data.endpointSpecSwarm
|
|
||||||
? JSON.stringify(data.endpointSpecSwarm, null, 2)
|
|
||||||
: null,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
@@ -318,8 +275,6 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
modeSwarm: data.modeSwarm,
|
modeSwarm: data.modeSwarm,
|
||||||
labelsSwarm: data.labelsSwarm,
|
labelsSwarm: data.labelsSwarm,
|
||||||
networkSwarm: data.networkSwarm,
|
networkSwarm: data.networkSwarm,
|
||||||
stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
|
|
||||||
endpointSpecSwarm: data.endpointSpecSwarm,
|
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Swarm settings updated");
|
toast.success("Swarm settings updated");
|
||||||
@@ -397,9 +352,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
language="json"
|
language="json"
|
||||||
placeholder={`{
|
placeholder={`{
|
||||||
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
|
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
|
||||||
"Interval" : 10000000000,
|
"Interval" : 10000,
|
||||||
"Timeout" : 10000000000,
|
"Timeout" : 10000,
|
||||||
"StartPeriod" : 10000000000,
|
"StartPeriod" : 10000,
|
||||||
"Retries" : 10
|
"Retries" : 10
|
||||||
}`}
|
}`}
|
||||||
className="h-[12rem] font-mono"
|
className="h-[12rem] font-mono"
|
||||||
@@ -452,9 +407,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
language="json"
|
language="json"
|
||||||
placeholder={`{
|
placeholder={`{
|
||||||
"Condition" : "on-failure",
|
"Condition" : "on-failure",
|
||||||
"Delay" : 10000000000,
|
"Delay" : 10000,
|
||||||
"MaxAttempts" : 10,
|
"MaxAttempts" : 10,
|
||||||
"Window" : 10000000000
|
"Window" : 10000
|
||||||
} `}
|
} `}
|
||||||
className="h-[12rem] font-mono"
|
className="h-[12rem] font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
@@ -574,9 +529,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
language="json"
|
language="json"
|
||||||
placeholder={`{
|
placeholder={`{
|
||||||
"Parallelism" : 1,
|
"Parallelism" : 1,
|
||||||
"Delay" : 10000000000,
|
"Delay" : 10000,
|
||||||
"FailureAction" : "continue",
|
"FailureAction" : "continue",
|
||||||
"Monitor" : 10000000000,
|
"Monitor" : 10000,
|
||||||
"MaxFailureRatio" : 10,
|
"MaxFailureRatio" : 10,
|
||||||
"Order" : "start-first"
|
"Order" : "start-first"
|
||||||
}`}
|
}`}
|
||||||
@@ -632,9 +587,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
language="json"
|
language="json"
|
||||||
placeholder={`{
|
placeholder={`{
|
||||||
"Parallelism" : 1,
|
"Parallelism" : 1,
|
||||||
"Delay" : 10000000000,
|
"Delay" : 10000,
|
||||||
"FailureAction" : "continue",
|
"FailureAction" : "continue",
|
||||||
"Monitor" : 10000000000,
|
"Monitor" : 10000,
|
||||||
"MaxFailureRatio" : 10,
|
"MaxFailureRatio" : 10,
|
||||||
"Order" : "start-first"
|
"Order" : "start-first"
|
||||||
}`}
|
}`}
|
||||||
@@ -819,118 +774,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="stopGracePeriodSwarm"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
|
|
||||||
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
|
||||||
Duration in nanoseconds
|
|
||||||
<HelpCircle className="size-4 text-muted-foreground" />
|
|
||||||
</FormDescription>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
className="w-full z-[999]"
|
|
||||||
align="start"
|
|
||||||
side="bottom"
|
|
||||||
>
|
|
||||||
<code>
|
|
||||||
<pre>
|
|
||||||
{`Enter duration in nanoseconds:
|
|
||||||
• 30000000000 - 30 seconds
|
|
||||||
• 120000000000 - 2 minutes
|
|
||||||
• 3600000000000 - 1 hour
|
|
||||||
• 0 - no grace period`}
|
|
||||||
</pre>
|
|
||||||
</code>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder="30000000000"
|
|
||||||
className="font-mono"
|
|
||||||
{...field}
|
|
||||||
value={field?.value?.toString() || ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
field.onChange(
|
|
||||||
e.target.value ? BigInt(e.target.value) : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<pre>
|
|
||||||
<FormMessage />
|
|
||||||
</pre>
|
|
||||||
</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,7 +1,6 @@
|
|||||||
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 { useFieldArray, 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 { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -29,13 +28,6 @@ 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>;
|
||||||
@@ -55,30 +47,22 @@ 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) {
|
if (data?.command) {
|
||||||
form.reset({
|
form.reset({
|
||||||
command: data?.command || "",
|
command: data?.command || "",
|
||||||
args: data?.args?.map((arg) => ({ value: arg })) || [],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
||||||
|
|
||||||
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");
|
||||||
@@ -116,65 +100,13 @@ export const AddCommand = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Command</FormLabel>
|
<FormLabel>Command</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="/bin/sh" {...field} />
|
<Input placeholder="Custom command" {...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">
|
||||||
|
|||||||
@@ -1,286 +0,0 @@
|
|||||||
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().optional(),
|
|
||||||
buildRegistryId: z.string().optional(),
|
|
||||||
})
|
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
// Both empty/none is valid
|
|
||||||
const buildServerIsNone =
|
|
||||||
!data.buildServerId || data.buildServerId === "none";
|
|
||||||
const buildRegistryIsNone =
|
|
||||||
!data.buildRegistryId || data.buildRegistryId === "none";
|
|
||||||
|
|
||||||
// Both should be either filled or empty
|
|
||||||
if (buildServerIsNone && buildRegistryIsNone) return true;
|
|
||||||
if (!buildServerIsNone && !buildRegistryIsNone) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Both Build Server and Build Registry must be selected together, or both set to None",
|
|
||||||
path: ["buildServerId"], // Show error on buildServerId field
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<AlertBlock type="info">
|
|
||||||
<strong>Note:</strong> Build Server and Build Registry must be
|
|
||||||
configured together. You can either select both or set both to None.
|
|
||||||
</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={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
// If setting to "none", also reset build registry to "none"
|
|
||||||
if (value === "none") {
|
|
||||||
form.setValue("buildRegistryId", "none");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
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={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
// If setting to "none", also reset build server to "none"
|
|
||||||
if (value === "none") {
|
|
||||||
form.setValue("buildServerId", "none");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -21,10 +21,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import {
|
import { Input } from "@/components/ui/input";
|
||||||
createConverter,
|
|
||||||
NumberInputWithSteps,
|
|
||||||
} from "@/components/ui/number-input";
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -33,23 +30,6 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const CPU_STEP = 0.25;
|
|
||||||
const MEMORY_STEP_MB = 256;
|
|
||||||
|
|
||||||
const formatNumber = (value: number, decimals = 2): string =>
|
|
||||||
Number.isInteger(value) ? String(value) : value.toFixed(decimals);
|
|
||||||
|
|
||||||
const cpuConverter = createConverter(1_000_000_000, (cpu) =>
|
|
||||||
cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
|
||||||
if (mb <= 0) return "";
|
|
||||||
return mb >= 1024
|
|
||||||
? `${formatNumber(mb / 1024)} GB`
|
|
||||||
: `${formatNumber(mb)} MB`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const addResourcesSchema = z.object({
|
const addResourcesSchema = z.object({
|
||||||
memoryReservation: z.string().optional(),
|
memoryReservation: z.string().optional(),
|
||||||
cpuLimit: z.string().optional(),
|
cpuLimit: z.string().optional(),
|
||||||
@@ -71,7 +51,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AddResources = z.infer<typeof addResourcesSchema>;
|
type AddResources = z.infer<typeof addResourcesSchema>;
|
||||||
|
|
||||||
export const ShowResources = ({ id, type }: Props) => {
|
export const ShowResources = ({ id, type }: Props) => {
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
@@ -171,10 +150,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<FormLabel>Memory Limit</FormLabel>
|
<FormLabel>Memory Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -184,20 +160,16 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Memory hard limit in bytes. Example: 1GB =
|
Memory hard limit in bytes. Example: 1GB =
|
||||||
1073741824 bytes. Use +/- buttons to adjust by
|
1073741824 bytes
|
||||||
256 MB.
|
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<NumberInputWithSteps
|
<Input
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="1073741824 (1GB in bytes)"
|
placeholder="1073741824 (1GB in bytes)"
|
||||||
step={MEMORY_STEP_MB}
|
{...field}
|
||||||
converter={memoryConverter}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -210,10 +182,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
name="memoryReservation"
|
name="memoryReservation"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<FormLabel>Memory Reservation</FormLabel>
|
<FormLabel>Memory Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -223,20 +192,16 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Memory soft limit in bytes. Example: 256MB =
|
Memory soft limit in bytes. Example: 256MB =
|
||||||
268435456 bytes. Use +/- buttons to adjust by 256
|
268435456 bytes
|
||||||
MB.
|
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<NumberInputWithSteps
|
<Input
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="268435456 (256MB in bytes)"
|
placeholder="268435456 (256MB in bytes)"
|
||||||
step={MEMORY_STEP_MB}
|
{...field}
|
||||||
converter={memoryConverter}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -250,10 +215,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<FormLabel>CPU Limit</FormLabel>
|
<FormLabel>CPU Limit</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -263,20 +225,17 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
CPU quota in units of 10^-9 CPUs. Example: 2
|
CPU quota in units of 10^-9 CPUs. Example: 2
|
||||||
CPUs = 2000000000. Use +/- buttons to adjust by
|
CPUs = 2000000000
|
||||||
0.25 CPU.
|
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<NumberInputWithSteps
|
<Input
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="2000000000 (2 CPUs)"
|
placeholder="2000000000 (2 CPUs)"
|
||||||
step={CPU_STEP}
|
{...field}
|
||||||
converter={cpuConverter}
|
value={field.value?.toString() || ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -290,10 +249,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div
|
<div className="flex items-center gap-2">
|
||||||
className="flex items-center gap-2"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<FormLabel>CPU Reservation</FormLabel>
|
<FormLabel>CPU Reservation</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
@@ -303,21 +259,14 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
CPU shares (relative weight). Example: 1 CPU =
|
CPU shares (relative weight). Example: 1 CPU =
|
||||||
1000000000. Use +/- buttons to adjust by 0.25
|
1000000000
|
||||||
CPU.
|
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<NumberInputWithSteps
|
<Input placeholder="1000000000 (1 CPU)" {...field} />
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
placeholder="1000000000 (1 CPU)"
|
|
||||||
step={CPU_STEP}
|
|
||||||
converter={cpuConverter}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import jsyaml from "js-yaml";
|
||||||
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 { parse, stringify, YAMLParseError } from "yaml";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
@@ -38,11 +38,11 @@ interface Props {
|
|||||||
|
|
||||||
export const validateAndFormatYAML = (yamlText: string) => {
|
export const validateAndFormatYAML = (yamlText: string) => {
|
||||||
try {
|
try {
|
||||||
const obj = parse(yamlText);
|
const obj = jsyaml.load(yamlText);
|
||||||
const formattedYaml = stringify(obj, { indent: 4 });
|
const formattedYaml = jsyaml.dump(obj, { indent: 4 });
|
||||||
return { valid: true, formattedYaml, error: null };
|
return { valid: true, formattedYaml, error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof YAMLParseError) {
|
if (error instanceof jsyaml.YAMLException) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
formattedYaml: yamlText,
|
formattedYaml: yamlText,
|
||||||
@@ -89,7 +89,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
|||||||
if (!valid) {
|
if (!valid) {
|
||||||
form.setError("traefikConfig", {
|
form.setError("traefikConfig", {
|
||||||
type: "manual",
|
type: "manual",
|
||||||
message: (error as string) || "Invalid YAML",
|
message: error || "Invalid YAML",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,13 +59,7 @@ const mySchema = z.discriminatedUnion("type", [
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("volume"),
|
type: z.literal("volume"),
|
||||||
volumeName: z
|
volumeName: z.string().min(1, "Volume name required"),
|
||||||
.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
|
||||||
@@ -324,7 +318,7 @@ export const AddVolumes = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="max-w-full max-w-[45rem]">
|
<FormItem>
|
||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>Content</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -333,7 +327,7 @@ export const AddVolumes = ({
|
|||||||
placeholder={`NODE_ENV=production
|
placeholder={`NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
`}
|
`}
|
||||||
className="h-96 font-mono "
|
className="h-96 font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -41,13 +41,7 @@ const mySchema = z.discriminatedUnion("type", [
|
|||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("volume"),
|
type: z.literal("volume"),
|
||||||
volumeName: z
|
volumeName: z.string().min(1, "Volume name required"),
|
||||||
.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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Cog } from "lucide-react";
|
import { Cog } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -20,39 +20,8 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
|
||||||
export const RAILPACK_VERSIONS = [
|
|
||||||
"0.15.4",
|
|
||||||
"0.15.3",
|
|
||||||
"0.15.2",
|
|
||||||
"0.15.1",
|
|
||||||
"0.15.0",
|
|
||||||
"0.14.0",
|
|
||||||
"0.13.0",
|
|
||||||
"0.12.0",
|
|
||||||
"0.11.0",
|
|
||||||
"0.10.0",
|
|
||||||
"0.9.2",
|
|
||||||
"0.9.1",
|
|
||||||
"0.9.0",
|
|
||||||
"0.8.0",
|
|
||||||
"0.7.0",
|
|
||||||
"0.6.0",
|
|
||||||
"0.5.0",
|
|
||||||
"0.4.0",
|
|
||||||
"0.3.0",
|
|
||||||
"0.2.2",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export enum BuildType {
|
export enum BuildType {
|
||||||
dockerfile = "dockerfile",
|
dockerfile = "dockerfile",
|
||||||
heroku_buildpacks = "heroku_buildpacks",
|
heroku_buildpacks = "heroku_buildpacks",
|
||||||
@@ -96,7 +65,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.railpack),
|
buildType: z.literal(BuildType.railpack),
|
||||||
railpackVersion: z.string().nullable().default("0.15.4"),
|
railpackVersion: z.string().nullable().default("0.2.2"),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.static),
|
buildType: z.literal(BuildType.static),
|
||||||
@@ -183,8 +152,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildType = form.watch("buildType");
|
const buildType = form.watch("buildType");
|
||||||
const railpackVersion = form.watch("railpackVersion");
|
|
||||||
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -196,14 +163,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
form.reset(resetData(typedData));
|
form.reset(resetData(typedData));
|
||||||
|
|
||||||
// Check if railpack version is manual (not in the predefined list)
|
|
||||||
if (
|
|
||||||
data.railpackVersion &&
|
|
||||||
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
|
|
||||||
) {
|
|
||||||
setIsManualRailpackVersion(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
|
|
||||||
@@ -227,7 +186,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
||||||
railpackVersion:
|
railpackVersion:
|
||||||
data.buildType === BuildType.railpack
|
data.buildType === BuildType.railpack
|
||||||
? data.railpackVersion || "0.15.4"
|
? data.railpackVersion || "0.2.2"
|
||||||
: null,
|
: null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -444,88 +403,23 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{buildType === BuildType.railpack && (
|
{buildType === BuildType.railpack && (
|
||||||
<>
|
<FormField
|
||||||
<FormField
|
control={form.control}
|
||||||
control={form.control}
|
name="railpackVersion"
|
||||||
name="railpackVersion"
|
render={({ field }) => (
|
||||||
render={({ field }) => (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel>Railpack Version</FormLabel>
|
||||||
<FormLabel>Railpack Version</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<Input
|
||||||
{isManualRailpackVersion ? (
|
placeholder="Railpack Version"
|
||||||
<div className="space-y-2">
|
{...field}
|
||||||
<Input
|
value={field.value ?? ""}
|
||||||
placeholder="Enter custom version (e.g., 0.15.4)"
|
/>
|
||||||
{...field}
|
</FormControl>
|
||||||
value={field.value ?? ""}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
<Button
|
)}
|
||||||
type="button"
|
/>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setIsManualRailpackVersion(false);
|
|
||||||
field.onChange("0.15.4");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Use predefined versions
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Select
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value === "manual") {
|
|
||||||
setIsManualRailpackVersion(true);
|
|
||||||
field.onChange("");
|
|
||||||
} else {
|
|
||||||
field.onChange(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
value={field.value ?? "0.15.4"}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select Railpack version" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="manual">
|
|
||||||
<span className="font-medium">
|
|
||||||
✏️ Manual (Custom Version)
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
{RAILPACK_VERSIONS.map((version) => (
|
|
||||||
<SelectItem key={version} value={version}>
|
|
||||||
v{version}
|
|
||||||
{version === "0.15.4" && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="ml-2 px-1 text-xs"
|
|
||||||
>
|
|
||||||
Latest
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Select a Railpack version or choose manual to enter a
|
|
||||||
custom version.{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/railwayapp/railpack/releases"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-primary underline underline-offset-4"
|
|
||||||
>
|
|
||||||
View releases
|
|
||||||
</a>
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
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,8 +1,6 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import { Loader2 } from "lucide-react";
|
||||||
import { Check, Copy, Loader2 } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -31,10 +29,9 @@ export const ShowDeployment = ({
|
|||||||
const [data, setData] = useState("");
|
const [data, setData] = useState("");
|
||||||
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (autoScroll && scrollRef.current) {
|
if (autoScroll && scrollRef.current) {
|
||||||
@@ -109,20 +106,6 @@ export const ShowDeployment = ({
|
|||||||
}
|
}
|
||||||
}, [filteredLogs, autoScroll]);
|
}, [filteredLogs, autoScroll]);
|
||||||
|
|
||||||
const handleCopy = () => {
|
|
||||||
const logContent = filteredLogs
|
|
||||||
.map(({ timestamp, message }: LogLine) =>
|
|
||||||
`${timestamp?.toISOString() || ""} ${message}`.trim(),
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const success = copy(logContent);
|
|
||||||
if (success) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const optionalErrors = parseLogs(errorMessage || "");
|
const optionalErrors = parseLogs(errorMessage || "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -145,27 +128,13 @@ export const ShowDeployment = ({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Deployment</DialogTitle>
|
<DialogTitle>Deployment</DialogTitle>
|
||||||
<DialogDescription className="flex items-center gap-2">
|
<DialogDescription className="flex items-center gap-2">
|
||||||
<span className="flex items-center gap-2">
|
<span>
|
||||||
See all the details of this deployment |{" "}
|
See all the details of this deployment |{" "}
|
||||||
<Badge variant="blank" className="text-xs">
|
<Badge variant="blank" className="text-xs">
|
||||||
{filteredLogs.length} lines
|
{filteredLogs.length} lines
|
||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7"
|
|
||||||
onClick={handleCopy}
|
|
||||||
disabled={filteredLogs.length === 0}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{serverId && (
|
{serverId && (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import {
|
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
|
||||||
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";
|
||||||
@@ -25,7 +17,6 @@ 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";
|
||||||
|
|
||||||
@@ -89,23 +80,6 @@ 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(() => {
|
||||||
@@ -143,10 +117,7 @@ export const ShowDeployments = ({
|
|||||||
See the last 10 deployments for this {type}
|
See the last 10 deployments for this {type}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center flex-wrap 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} />
|
||||||
)}
|
)}
|
||||||
@@ -246,183 +217,122 @@ 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) => (
|
||||||
const titleText = deployment?.title?.trim() || "";
|
<div
|
||||||
const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH;
|
key={deployment.deploymentId}
|
||||||
const isExpanded = expandedDescriptions.has(
|
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||||
deployment.deploymentId,
|
>
|
||||||
);
|
<div className="flex flex-col">
|
||||||
|
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||||
return (
|
{index + 1}. {deployment.status}
|
||||||
<div
|
<StatusTooltip
|
||||||
key={deployment.deploymentId}
|
status={deployment?.status}
|
||||||
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
className="size-2.5"
|
||||||
>
|
/>
|
||||||
<div className="flex flex-1 flex-col min-w-0">
|
</span>
|
||||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{index + 1}. {deployment.status}
|
{deployment.title}
|
||||||
<StatusTooltip
|
</span>
|
||||||
status={deployment?.status}
|
{deployment.description && (
|
||||||
className="size-2.5"
|
<span className="break-all text-sm text-muted-foreground">
|
||||||
/>
|
{deployment.description}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-1">
|
</div>
|
||||||
<span className="break-words text-sm text-muted-foreground whitespace-pre-wrap">
|
<div className="flex flex-col items-end gap-2">
|
||||||
{isExpanded || !needsTruncation
|
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||||
? titleText
|
<DateTooltip date={deployment.createdAt} />
|
||||||
: truncateDescription(titleText)}
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
</span>
|
<Badge
|
||||||
{needsTruncation && (
|
variant="outline"
|
||||||
<button
|
className="text-[10px] gap-1 flex items-center"
|
||||||
type="button"
|
>
|
||||||
onClick={() => {
|
<Clock className="size-3" />
|
||||||
const next = new Set(expandedDescriptions);
|
{formatDuration(
|
||||||
if (next.has(deployment.deploymentId)) {
|
Math.floor(
|
||||||
next.delete(deployment.deploymentId);
|
(new Date(deployment.finishedAt).getTime() -
|
||||||
} else {
|
new Date(deployment.startedAt).getTime()) /
|
||||||
next.add(deployment.deploymentId);
|
1000,
|
||||||
}
|
),
|
||||||
setExpandedDescriptions(next);
|
)}
|
||||||
}}
|
</Badge>
|
||||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mt-1 cursor-pointer"
|
)}
|
||||||
aria-label={
|
|
||||||
isExpanded
|
|
||||||
? "Collapse commit message"
|
|
||||||
: "Expand commit message"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<>
|
|
||||||
<ChevronUp className="size-3" />
|
|
||||||
Show less
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronDown className="size-3" />
|
|
||||||
Show more
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
||||||
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
|
|
||||||
<div className="text-sm capitalize text-muted-foreground flex flex-wrap 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 w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
|
<div className="flex flex-row items-center gap-2">
|
||||||
{deployment.pid && deployment.status === "running" && (
|
{deployment.pid && deployment.status === "running" && (
|
||||||
|
<DialogAction
|
||||||
|
title="Kill Process"
|
||||||
|
description="Are you sure you want to kill the process?"
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await killProcess({
|
||||||
|
deploymentId: deployment.deploymentId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Process killed successfully");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error killing process");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
isLoading={isKillingProcess}
|
||||||
|
>
|
||||||
|
Kill Process
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveLog(deployment);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{deployment?.rollback &&
|
||||||
|
deployment.status === "done" &&
|
||||||
|
type === "application" && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Kill Process"
|
title="Rollback to this deployment"
|
||||||
description="Are you sure you want to kill the process?"
|
description="Are you sure you want to rollback to this deployment?"
|
||||||
type="default"
|
type="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await killProcess({
|
await rollback({
|
||||||
deploymentId: deployment.deploymentId,
|
rollbackId: deployment.rollback.rollbackId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Process killed successfully");
|
toast.success(
|
||||||
|
"Rollback initiated successfully",
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error killing process");
|
toast.error("Error initiating rollback");
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isKillingProcess}
|
isLoading={isRollingBack}
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
>
|
||||||
Kill Process
|
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
Rollback
|
||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setActiveLog(deployment);
|
|
||||||
}}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{deployment?.rollback &&
|
|
||||||
deployment.status === "done" &&
|
|
||||||
type === "application" && (
|
|
||||||
<DialogAction
|
|
||||||
title="Rollback to this deployment"
|
|
||||||
description={
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<p>
|
|
||||||
Are you sure you want to rollback to this
|
|
||||||
deployment?
|
|
||||||
</p>
|
|
||||||
<AlertBlock type="info" className="text-sm">
|
|
||||||
Please wait a few seconds while the image is
|
|
||||||
pulled from the registry. Your application
|
|
||||||
should be running shortly.
|
|
||||||
</AlertBlock>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
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}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
<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={activeLog?.buildServerId || serverId}
|
serverId={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,13 +46,7 @@ export type CacheType = "fetch" | "cache";
|
|||||||
|
|
||||||
export const domain = z
|
export const domain = z
|
||||||
.object({
|
.object({
|
||||||
host: z
|
host: z.string().min(1, { message: "Add a hostname" }),
|
||||||
.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(),
|
||||||
@@ -208,8 +202,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
const certificateType = form.watch("certificateType");
|
const certificateType = form.watch("certificateType");
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domainType = form.watch("domainType");
|
const domainType = form.watch("domainType");
|
||||||
const host = form.watch("host");
|
|
||||||
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -307,13 +299,6 @@ 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"
|
||||||
@@ -504,13 +489,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
to make your traefik.me domain work.
|
to make your traefik.me domain work.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
{isTraefikMeDomain && (
|
|
||||||
<AlertBlock type="info">
|
|
||||||
<strong>Note:</strong> traefik.me is a public HTTP
|
|
||||||
service and does not support SSL/HTTPS. HTTPS and
|
|
||||||
certificate options will not have any effect.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
<FormLabel>Host</FormLabel>
|
<FormLabel>Host</FormLabel>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -108,21 +108,6 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
|
||||||
e.preventDefault();
|
|
||||||
form.handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [form, onSubmit, isLoading]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
|
|||||||
@@ -5,23 +5,13 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import {
|
import { Form } from "@/components/ui/form";
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Secrets } from "@/components/ui/secrets";
|
import { Secrets } from "@/components/ui/secrets";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
env: z.string(),
|
env: z.string(),
|
||||||
buildArgs: z.string(),
|
buildArgs: z.string(),
|
||||||
buildSecrets: z.string(),
|
|
||||||
createEnvFile: z.boolean(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||||
@@ -47,8 +37,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
env: "",
|
env: "",
|
||||||
buildArgs: "",
|
buildArgs: "",
|
||||||
buildSecrets: "",
|
|
||||||
createEnvFile: true,
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addEnvironmentSchema),
|
resolver: zodResolver(addEnvironmentSchema),
|
||||||
});
|
});
|
||||||
@@ -56,21 +44,15 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
// Watch form values
|
// Watch form values
|
||||||
const currentEnv = form.watch("env");
|
const currentEnv = form.watch("env");
|
||||||
const currentBuildArgs = form.watch("buildArgs");
|
const currentBuildArgs = form.watch("buildArgs");
|
||||||
const currentBuildSecrets = form.watch("buildSecrets");
|
|
||||||
const currentCreateEnvFile = form.watch("createEnvFile");
|
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
currentEnv !== (data?.env || "") ||
|
currentEnv !== (data?.env || "") ||
|
||||||
currentBuildArgs !== (data?.buildArgs || "") ||
|
currentBuildArgs !== (data?.buildArgs || "");
|
||||||
currentBuildSecrets !== (data?.buildSecrets || "") ||
|
|
||||||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
env: data.env || "",
|
env: data.env || "",
|
||||||
buildArgs: data.buildArgs || "",
|
buildArgs: data.buildArgs || "",
|
||||||
buildSecrets: data.buildSecrets || "",
|
|
||||||
createEnvFile: data.createEnvFile ?? true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -79,8 +61,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
mutateAsync({
|
mutateAsync({
|
||||||
env: formData.env,
|
env: formData.env,
|
||||||
buildArgs: formData.buildArgs,
|
buildArgs: formData.buildArgs,
|
||||||
buildSecrets: formData.buildSecrets,
|
|
||||||
createEnvFile: formData.createEnvFile,
|
|
||||||
applicationId,
|
applicationId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -96,26 +76,9 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
form.reset({
|
form.reset({
|
||||||
env: data?.env || "",
|
env: data?.env || "",
|
||||||
buildArgs: data?.buildArgs || "",
|
buildArgs: data?.buildArgs || "",
|
||||||
buildSecrets: data?.buildSecrets || "",
|
|
||||||
createEnvFile: data?.createEnvFile ?? true,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
|
||||||
e.preventDefault();
|
|
||||||
form.handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [form, onSubmit, isLoading]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background px-6 pb-6">
|
<Card className="bg-background px-6 pb-6">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -141,14 +104,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
{data?.buildType === "dockerfile" && (
|
{data?.buildType === "dockerfile" && (
|
||||||
<Secrets
|
<Secrets
|
||||||
name="buildArgs"
|
name="buildArgs"
|
||||||
title="Build-time Arguments"
|
title="Build-time Variables"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
Arguments are available only at build-time. See
|
Available only at build-time. See documentation
|
||||||
documentation
|
|
||||||
<a
|
<a
|
||||||
className="text-primary"
|
className="text-primary"
|
||||||
href="https://docs.docker.com/build/building/variables/"
|
href="https://docs.docker.com/build/guide/build-args/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
@@ -160,53 +122,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
placeholder="NPM_TOKEN=xyz"
|
placeholder="NPM_TOKEN=xyz"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{data?.buildType === "dockerfile" && (
|
|
||||||
<Secrets
|
|
||||||
name="buildSecrets"
|
|
||||||
title="Build-time Secrets"
|
|
||||||
description={
|
|
||||||
<span>
|
|
||||||
Secrets are specially designed for sensitive information and
|
|
||||||
are only available at build-time. See documentation
|
|
||||||
<a
|
|
||||||
className="text-primary"
|
|
||||||
href="https://docs.docker.com/build/building/secrets/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
placeholder="NPM_TOKEN=xyz"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{data?.buildType === "dockerfile" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="createEnvFile"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Create Environment File</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
When enabled, an .env file will be created in the same
|
|
||||||
directory as your Dockerfile during the build process.
|
|
||||||
Disable this if you don't want to generate an environment
|
|
||||||
file.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-row justify-end gap-2">
|
<div className="flex flex-row justify-end gap-2">
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules || false,
|
enableSubmodules: data.enableSubmodules || false,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isLoading } =
|
||||||
api.application.saveGitProvider.useMutation();
|
api.application.saveGitProdiver.useMutation();
|
||||||
|
|
||||||
const form = useForm<GitProvider>({
|
const form = useForm<GitProvider>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<FormItem className="md:col-span-2 flex flex-col">
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.gitlabPathNamespace && (
|
{field.value.owner && field.value.repo && (
|
||||||
<Link
|
<Link
|
||||||
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
|||||||
@@ -86,9 +86,6 @@ export const AddPreviewDomain = ({
|
|||||||
resolver: zodResolver(domain),
|
resolver: zodResolver(domain),
|
||||||
});
|
});
|
||||||
|
|
||||||
const host = form.watch("host");
|
|
||||||
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -160,13 +157,6 @@ export const AddPreviewDomain = ({
|
|||||||
name="host"
|
name="host"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
{isTraefikMeDomain && (
|
|
||||||
<AlertBlock type="info">
|
|
||||||
<strong>Note:</strong> traefik.me is a public HTTP
|
|
||||||
service and does not support SSL/HTTPS. HTTPS and
|
|
||||||
certificate options will not have any effect.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
<FormLabel>Host</FormLabel>
|
<FormLabel>Host</FormLabel>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Hammer,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
PenSquare,
|
PenSquare,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
@@ -23,13 +22,6 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||||
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||||
@@ -46,9 +38,6 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||||
api.previewDeployment.delete.useMutation();
|
api.previewDeployment.delete.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: redeployPreviewDeployment } =
|
|
||||||
api.previewDeployment.redeploy.useMutation();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: previewDeployments,
|
data: previewDeployments,
|
||||||
refetch: refetchPreviewDeployments,
|
refetch: refetchPreviewDeployments,
|
||||||
@@ -57,8 +46,6 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
{ applicationId },
|
{ applicationId },
|
||||||
{
|
{
|
||||||
enabled: !!applicationId,
|
enabled: !!applicationId,
|
||||||
refetchInterval: (data) =>
|
|
||||||
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -195,68 +182,7 @@ 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>
|
|
||||||
|
|
||||||
<DialogAction
|
|
||||||
title="Rebuild Preview Deployment"
|
|
||||||
description="Are you sure you want to rebuild this preview deployment?"
|
|
||||||
type="default"
|
|
||||||
onClick={async () => {
|
|
||||||
await redeployPreviewDeployment({
|
|
||||||
previewDeploymentId:
|
|
||||||
deployment.previewDeploymentId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(
|
|
||||||
"Preview deployment rebuild started",
|
|
||||||
);
|
|
||||||
refetchPreviewDeployments();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(
|
|
||||||
"Error rebuilding preview deployment",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
isLoading={status === "running"}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Hammer className="size-4" />
|
|
||||||
Rebuild
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPrimitive.Portal>
|
|
||||||
<TooltipContent
|
|
||||||
sideOffset={5}
|
|
||||||
className="z-[60]"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Rebuild the preview deployment without
|
|
||||||
downloading new code
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
|
|
||||||
<AddPreviewDomain
|
<AddPreviewDomain
|
||||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -47,7 +46,6 @@ const schema = z
|
|||||||
.object({
|
.object({
|
||||||
env: z.string(),
|
env: z.string(),
|
||||||
buildArgs: z.string(),
|
buildArgs: z.string(),
|
||||||
buildSecrets: z.string(),
|
|
||||||
wildcardDomain: z.string(),
|
wildcardDomain: z.string(),
|
||||||
port: z.number(),
|
port: z.number(),
|
||||||
previewLimit: z.number(),
|
previewLimit: z.number(),
|
||||||
@@ -101,8 +99,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const previewHttps = form.watch("previewHttps");
|
const previewHttps = form.watch("previewHttps");
|
||||||
const wildcardDomain = form.watch("wildcardDomain");
|
|
||||||
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||||
@@ -113,7 +109,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
form.reset({
|
form.reset({
|
||||||
env: data.previewEnv || "",
|
env: data.previewEnv || "",
|
||||||
buildArgs: data.previewBuildArgs || "",
|
buildArgs: data.previewBuildArgs || "",
|
||||||
buildSecrets: data.previewBuildSecrets || "",
|
|
||||||
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
||||||
port: data.previewPort || 3000,
|
port: data.previewPort || 3000,
|
||||||
previewLabels: data.previewLabels || [],
|
previewLabels: data.previewLabels || [],
|
||||||
@@ -123,7 +118,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
previewCertificateType: data.previewCertificateType || "none",
|
previewCertificateType: data.previewCertificateType || "none",
|
||||||
previewCustomCertResolver: data.previewCustomCertResolver || "",
|
previewCustomCertResolver: data.previewCustomCertResolver || "",
|
||||||
previewRequireCollaboratorPermissions:
|
previewRequireCollaboratorPermissions:
|
||||||
data.previewRequireCollaboratorPermissions ?? true,
|
data.previewRequireCollaboratorPermissions || true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
@@ -132,7 +127,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
updateApplication({
|
updateApplication({
|
||||||
previewEnv: formData.env,
|
previewEnv: formData.env,
|
||||||
previewBuildArgs: formData.buildArgs,
|
previewBuildArgs: formData.buildArgs,
|
||||||
previewBuildSecrets: formData.buildSecrets,
|
|
||||||
previewWildcard: formData.wildcardDomain,
|
previewWildcard: formData.wildcardDomain,
|
||||||
previewPort: formData.port,
|
previewPort: formData.port,
|
||||||
previewLabels: formData.previewLabels,
|
previewLabels: formData.previewLabels,
|
||||||
@@ -171,13 +165,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{isTraefikMeDomain && (
|
|
||||||
<AlertBlock type="info">
|
|
||||||
<strong>Note:</strong> traefik.me is a public HTTP service and
|
|
||||||
does not support SSL/HTTPS. HTTPS and certificate options will
|
|
||||||
not have any effect.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
@@ -480,37 +467,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
{data?.buildType === "dockerfile" && (
|
{data?.buildType === "dockerfile" && (
|
||||||
<Secrets
|
<Secrets
|
||||||
name="buildArgs"
|
name="buildArgs"
|
||||||
title="Build-time Arguments"
|
title="Build-time Variables"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
Arguments are available only at build-time. See
|
Available only at build-time. See documentation
|
||||||
documentation
|
|
||||||
<a
|
<a
|
||||||
className="text-primary"
|
className="text-primary"
|
||||||
href="https://docs.docker.com/build/building/variables/"
|
href="https://docs.docker.com/build/guide/build-args/"
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
here
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
placeholder="NPM_TOKEN=xyz"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{data?.buildType === "dockerfile" && (
|
|
||||||
<Secrets
|
|
||||||
name="buildSecrets"
|
|
||||||
title="Build-time Secrets"
|
|
||||||
description={
|
|
||||||
<span>
|
|
||||||
Secrets are specially designed for sensitive information
|
|
||||||
and are only available at build-time. See
|
|
||||||
documentation
|
|
||||||
<a
|
|
||||||
className="text-primary"
|
|
||||||
href="https://docs.docker.com/build/building/secrets/"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import Link from "next/link";
|
import { 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";
|
||||||
@@ -21,37 +20,13 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z.object({
|
||||||
.object({
|
rollbackActive: z.boolean(),
|
||||||
rollbackActive: z.boolean(),
|
});
|
||||||
rollbackRegistryId: z.string().optional(),
|
|
||||||
})
|
|
||||||
.superRefine((values, ctx) => {
|
|
||||||
if (
|
|
||||||
values.rollbackActive &&
|
|
||||||
(!values.rollbackRegistryId || values.rollbackRegistryId === "none")
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["rollbackRegistryId"],
|
|
||||||
message: "Registry is required when rollbacks are enabled",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
@@ -74,33 +49,17 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
|||||||
const { mutateAsync: updateApplication, isLoading } =
|
const { mutateAsync: updateApplication, isLoading } =
|
||||||
api.application.update.useMutation();
|
api.application.update.useMutation();
|
||||||
|
|
||||||
const { data: registries } = api.registry.all.useQuery();
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
rollbackActive: application?.rollbackActive ?? false,
|
rollbackActive: application?.rollbackActive ?? false,
|
||||||
rollbackRegistryId: application?.rollbackRegistryId || "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (application) {
|
|
||||||
form.reset({
|
|
||||||
rollbackActive: application.rollbackActive ?? false,
|
|
||||||
rollbackRegistryId: application.rollbackRegistryId || "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [application, form]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: FormValues) => {
|
const onSubmit = async (data: FormValues) => {
|
||||||
await updateApplication({
|
await updateApplication({
|
||||||
applicationId,
|
applicationId,
|
||||||
rollbackActive: data.rollbackActive,
|
rollbackActive: data.rollbackActive,
|
||||||
rollbackRegistryId:
|
|
||||||
data.rollbackRegistryId === "none" || !data.rollbackRegistryId
|
|
||||||
? null
|
|
||||||
: data.rollbackRegistryId,
|
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Rollback settings updated");
|
toast.success("Rollback settings updated");
|
||||||
@@ -153,65 +112,6 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{form.watch("rollbackActive") && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="rollbackRegistryId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Rollback 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>
|
|
||||||
{!registries || registries.length === 0 ? (
|
|
||||||
<FormDescription className="text-amber-600 dark:text-amber-500">
|
|
||||||
No registries available. Please{" "}
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings/registry"
|
|
||||||
className="underline font-medium hover:text-amber-700 dark:hover:text-amber-400"
|
|
||||||
>
|
|
||||||
configure a registry
|
|
||||||
</Link>{" "}
|
|
||||||
first to enable rollbacks.
|
|
||||||
</FormDescription>
|
|
||||||
) : (
|
|
||||||
<FormDescription>
|
|
||||||
Select a registry where rollback images will be stored.
|
|
||||||
</FormDescription>
|
|
||||||
)}
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||||
Save Settings
|
Save Settings
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
|
||||||
ChevronsUpDown,
|
|
||||||
DatabaseZap,
|
DatabaseZap,
|
||||||
Info,
|
Info,
|
||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
@@ -9,20 +7,12 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { type Control, 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 { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -41,12 +31,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -64,7 +48,6 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import type { CacheType } from "../domains/handle-domain";
|
import type { CacheType } from "../domains/handle-domain";
|
||||||
import { getTimezoneLabel, TIMEZONES } from "./timezones";
|
|
||||||
|
|
||||||
export const commonCronExpressions = [
|
export const commonCronExpressions = [
|
||||||
{ label: "Every minute", value: "* * * * *" },
|
{ label: "Every minute", value: "* * * * *" },
|
||||||
@@ -74,7 +57,6 @@ export const commonCronExpressions = [
|
|||||||
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
|
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
|
||||||
{ label: "Every 15 minutes", value: "*/15 * * * *" },
|
{ label: "Every 15 minutes", value: "*/15 * * * *" },
|
||||||
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
|
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
|
||||||
{ label: "Custom", value: "custom" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
@@ -92,7 +74,6 @@ const formSchema = z
|
|||||||
"dokploy-server",
|
"dokploy-server",
|
||||||
]),
|
]),
|
||||||
script: z.string(),
|
script: z.string(),
|
||||||
timezone: z.string().optional(),
|
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.scheduleType === "compose" && !data.serviceName) {
|
if (data.scheduleType === "compose" && !data.serviceName) {
|
||||||
@@ -134,91 +115,10 @@ interface Props {
|
|||||||
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScheduleFormField = ({
|
|
||||||
name,
|
|
||||||
formControl,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
formControl: Control<any>;
|
|
||||||
}) => {
|
|
||||||
const [selectedOption, setSelectedOption] = useState("");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormField
|
|
||||||
control={formControl}
|
|
||||||
name={name}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="flex items-center gap-2">
|
|
||||||
Schedule
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Cron expression format: minute hour day month weekday</p>
|
|
||||||
<p>Example: 0 0 * * * (daily at midnight)</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</FormLabel>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Select
|
|
||||||
value={selectedOption}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
setSelectedOption(value);
|
|
||||||
field.onChange(value === "custom" ? "" : value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a predefined schedule" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{commonCronExpressions.map((expr) => (
|
|
||||||
<SelectItem key={expr.value} value={expr.value}>
|
|
||||||
{expr.label}
|
|
||||||
{expr.value !== "custom" && ` (${expr.value})`}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<div className="relative">
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
|
||||||
{...field}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
const commonExpression = commonCronExpressions.find(
|
|
||||||
(expression) => expression.value === value,
|
|
||||||
);
|
|
||||||
if (commonExpression) {
|
|
||||||
setSelectedOption(commonExpression.value);
|
|
||||||
} else {
|
|
||||||
setSelectedOption("custom");
|
|
||||||
}
|
|
||||||
field.onChange(e);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<FormDescription>
|
|
||||||
Choose a predefined schedule or enter a custom cron expression
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@@ -231,7 +131,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
serviceName: "",
|
serviceName: "",
|
||||||
scheduleType: scheduleType || "application",
|
scheduleType: scheduleType || "application",
|
||||||
script: "",
|
script: "",
|
||||||
timezone: undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,7 +169,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
serviceName: schedule.serviceName || "",
|
serviceName: schedule.serviceName || "",
|
||||||
scheduleType: schedule.scheduleType,
|
scheduleType: schedule.scheduleType,
|
||||||
script: schedule.script || "",
|
script: schedule.script || "",
|
||||||
timezone: schedule.timezone || undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, schedule, scheduleId]);
|
}, [form, schedule, scheduleId]);
|
||||||
@@ -479,18 +377,13 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScheduleFormField
|
|
||||||
name="cronExpression"
|
|
||||||
formControl={form.control}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="timezone"
|
name="cronExpression"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="flex items-center gap-2">
|
<FormLabel className="flex items-center gap-2">
|
||||||
Timezone
|
Schedule
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -498,69 +391,45 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Select a timezone for the schedule. If not
|
Cron expression format: minute hour day month
|
||||||
specified, UTC will be used.
|
weekday
|
||||||
</p>
|
</p>
|
||||||
|
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Popover>
|
<div className="flex flex-col gap-2">
|
||||||
<PopoverTrigger asChild>
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<SelectTrigger>
|
||||||
variant="outline"
|
<SelectValue placeholder="Select a predefined schedule" />
|
||||||
className={cn(
|
</SelectTrigger>
|
||||||
"w-full justify-between !bg-input",
|
|
||||||
!field.value && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{getTimezoneLabel(field.value)}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
<SelectContent>
|
||||||
<PopoverContent className="w-[400px] p-0" align="start">
|
{commonCronExpressions.map((expr) => (
|
||||||
<Command>
|
<SelectItem key={expr.value} value={expr.value}>
|
||||||
<CommandInput
|
{expr.label} ({expr.value})
|
||||||
placeholder="Search timezone..."
|
</SelectItem>
|
||||||
className="h-9"
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||||
|
{...field}
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
</FormControl>
|
||||||
<CommandEmpty>No timezone found.</CommandEmpty>
|
</div>
|
||||||
<ScrollArea className="h-72">
|
</div>
|
||||||
{Object.entries(TIMEZONES).map(
|
|
||||||
([region, zones]) => (
|
|
||||||
<CommandGroup key={region} heading={region}>
|
|
||||||
{zones.map((tz) => (
|
|
||||||
<CommandItem
|
|
||||||
key={tz.value}
|
|
||||||
value={`${region} ${tz.label} ${tz.value}`}
|
|
||||||
onSelect={() => {
|
|
||||||
field.onChange(tz.value);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tz.value}
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"ml-auto h-4 w-4",
|
|
||||||
field.value === tz.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Optional: Choose a timezone for the schedule execution time
|
Choose a predefined schedule or enter a custom cron
|
||||||
|
expression
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -34,9 +33,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||||
const [runningSchedules, setRunningSchedules] = useState<Set<string>>(
|
|
||||||
new Set(),
|
|
||||||
);
|
|
||||||
const {
|
const {
|
||||||
data: schedules,
|
data: schedules,
|
||||||
isLoading: isLoadingSchedules,
|
isLoading: isLoadingSchedules,
|
||||||
@@ -50,27 +46,14 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
||||||
api.schedule.delete.useMutation();
|
api.schedule.delete.useMutation();
|
||||||
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
|
|
||||||
|
|
||||||
const handleRunManually = async (scheduleId: string) => {
|
const { mutateAsync: runManually, isLoading } =
|
||||||
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
|
api.schedule.runManually.useMutation();
|
||||||
try {
|
|
||||||
await runManually({ scheduleId });
|
|
||||||
toast.success("Schedule run successfully");
|
|
||||||
await refetchSchedules();
|
|
||||||
} catch {
|
|
||||||
toast.error("Error running schedule");
|
|
||||||
} finally {
|
|
||||||
setRunningSchedules((prev) => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(scheduleId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||||
@@ -84,6 +67,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
Schedule tasks to run automatically at specified intervals.
|
Schedule tasks to run automatically at specified intervals.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{schedules && schedules.length > 0 && (
|
{schedules && schedules.length > 0 && (
|
||||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||||
)}
|
)}
|
||||||
@@ -91,7 +75,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
{isLoadingSchedules ? (
|
{isLoadingSchedules ? (
|
||||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
<span className="text-sm text-muted-foreground/70">
|
<span className="text-sm text-muted-foreground/70">
|
||||||
Loading scheduled tasks...
|
Loading scheduled tasks...
|
||||||
@@ -107,13 +91,13 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={schedule.scheduleId}
|
key={schedule.scheduleId}
|
||||||
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
|
className="flex items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 w-full sm:w-auto">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex flex-shrink-0 h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
<div className="flex flex-shrink-0 h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||||
<Clock className="size-4 text-primary/70" />
|
<Clock className="size-4 text-primary/70" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 w-full sm:w-auto">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h3 className="text-sm font-medium leading-none [overflow-wrap:anywhere] line-clamp-3">
|
<h3 className="text-sm font-medium leading-none [overflow-wrap:anywhere] line-clamp-3">
|
||||||
{schedule.name}
|
{schedule.name}
|
||||||
@@ -148,15 +132,16 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{schedule.command && (
|
{schedule.command && (
|
||||||
<div className="flex items-start gap-2 max-w-full">
|
<div className="flex items-center gap-2">
|
||||||
<Terminal className="size-3.5 text-muted-foreground/70 flex-shrink-0 mt-0.5" />
|
<Terminal className="size-3.5 text-muted-foreground/70" />
|
||||||
<code className="font-mono text-[10px] text-muted-foreground/70 break-all max-w-[calc(100%-20px)]">
|
<code className="font-mono text-[10px] text-muted-foreground/70">
|
||||||
{schedule.command}
|
{schedule.command}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 md:gap-1.5">
|
<div className="flex items-center gap-0.5 md:gap-1.5">
|
||||||
<ShowDeploymentsModal
|
<ShowDeploymentsModal
|
||||||
id={schedule.scheduleId}
|
id={schedule.scheduleId}
|
||||||
@@ -164,9 +149,10 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
serverId={serverId || undefined}
|
serverId={serverId || undefined}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<ClipboardList className="size-4 transition-colors" />
|
<ClipboardList className="size-4 transition-colors " />
|
||||||
</Button>
|
</Button>
|
||||||
</ShowDeploymentsModal>
|
</ShowDeploymentsModal>
|
||||||
|
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -174,26 +160,37 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={runningSchedules.has(schedule.scheduleId)}
|
isLoading={isLoading}
|
||||||
onClick={() =>
|
onClick={async () => {
|
||||||
handleRunManually(schedule.scheduleId)
|
toast.success("Schedule run successfully");
|
||||||
}
|
|
||||||
|
await runManually({
|
||||||
|
scheduleId: schedule.scheduleId,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, 1500),
|
||||||
|
);
|
||||||
|
refetchSchedules();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error running schedule");
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{runningSchedules.has(schedule.scheduleId) ? (
|
<Play className="size-4 transition-colors" />
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play className="size-4 transition-colors" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Run Manual Schedule</TooltipContent>
|
<TooltipContent>Run Manual Schedule</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<HandleSchedules
|
<HandleSchedules
|
||||||
scheduleId={schedule.scheduleId}
|
scheduleId={schedule.scheduleId}
|
||||||
id={id}
|
id={id}
|
||||||
scheduleType={scheduleType}
|
scheduleType={scheduleType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete Schedule"
|
title="Delete Schedule"
|
||||||
description="Are you sure you want to delete this schedule?"
|
description="Are you sure you want to delete this schedule?"
|
||||||
@@ -217,8 +214,8 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-red-500/10"
|
className="group hover:bg-red-500/10 "
|
||||||
disabled={isDeleting}
|
isLoading={isDeleting}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,458 +0,0 @@
|
|||||||
// Complete list of IANA timezones grouped by region
|
|
||||||
export const TIMEZONES: Record<
|
|
||||||
string,
|
|
||||||
Array<{ label: string; value: string }>
|
|
||||||
> = {
|
|
||||||
Common: [{ label: "UTC (Coordinated Universal Time)", value: "UTC" }],
|
|
||||||
Africa: [
|
|
||||||
{ label: "Abidjan", value: "Africa/Abidjan" },
|
|
||||||
{ label: "Accra", value: "Africa/Accra" },
|
|
||||||
{ label: "Addis Ababa", value: "Africa/Addis_Ababa" },
|
|
||||||
{ label: "Algiers", value: "Africa/Algiers" },
|
|
||||||
{ label: "Asmara", value: "Africa/Asmara" },
|
|
||||||
{ label: "Bamako", value: "Africa/Bamako" },
|
|
||||||
{ label: "Bangui", value: "Africa/Bangui" },
|
|
||||||
{ label: "Banjul", value: "Africa/Banjul" },
|
|
||||||
{ label: "Bissau", value: "Africa/Bissau" },
|
|
||||||
{ label: "Blantyre", value: "Africa/Blantyre" },
|
|
||||||
{ label: "Brazzaville", value: "Africa/Brazzaville" },
|
|
||||||
{ label: "Bujumbura", value: "Africa/Bujumbura" },
|
|
||||||
{ label: "Cairo", value: "Africa/Cairo" },
|
|
||||||
{ label: "Casablanca", value: "Africa/Casablanca" },
|
|
||||||
{ label: "Ceuta", value: "Africa/Ceuta" },
|
|
||||||
{ label: "Conakry", value: "Africa/Conakry" },
|
|
||||||
{ label: "Dakar", value: "Africa/Dakar" },
|
|
||||||
{ label: "Dar es Salaam", value: "Africa/Dar_es_Salaam" },
|
|
||||||
{ label: "Djibouti", value: "Africa/Djibouti" },
|
|
||||||
{ label: "Douala", value: "Africa/Douala" },
|
|
||||||
{ label: "El Aaiun", value: "Africa/El_Aaiun" },
|
|
||||||
{ label: "Freetown", value: "Africa/Freetown" },
|
|
||||||
{ label: "Gaborone", value: "Africa/Gaborone" },
|
|
||||||
{ label: "Harare", value: "Africa/Harare" },
|
|
||||||
{ label: "Johannesburg", value: "Africa/Johannesburg" },
|
|
||||||
{ label: "Juba", value: "Africa/Juba" },
|
|
||||||
{ label: "Kampala", value: "Africa/Kampala" },
|
|
||||||
{ label: "Khartoum", value: "Africa/Khartoum" },
|
|
||||||
{ label: "Kigali", value: "Africa/Kigali" },
|
|
||||||
{ label: "Kinshasa", value: "Africa/Kinshasa" },
|
|
||||||
{ label: "Lagos", value: "Africa/Lagos" },
|
|
||||||
{ label: "Libreville", value: "Africa/Libreville" },
|
|
||||||
{ label: "Lome", value: "Africa/Lome" },
|
|
||||||
{ label: "Luanda", value: "Africa/Luanda" },
|
|
||||||
{ label: "Lubumbashi", value: "Africa/Lubumbashi" },
|
|
||||||
{ label: "Lusaka", value: "Africa/Lusaka" },
|
|
||||||
{ label: "Malabo", value: "Africa/Malabo" },
|
|
||||||
{ label: "Maputo", value: "Africa/Maputo" },
|
|
||||||
{ label: "Maseru", value: "Africa/Maseru" },
|
|
||||||
{ label: "Mbabane", value: "Africa/Mbabane" },
|
|
||||||
{ label: "Mogadishu", value: "Africa/Mogadishu" },
|
|
||||||
{ label: "Monrovia", value: "Africa/Monrovia" },
|
|
||||||
{ label: "Nairobi", value: "Africa/Nairobi" },
|
|
||||||
{ label: "Ndjamena", value: "Africa/Ndjamena" },
|
|
||||||
{ label: "Niamey", value: "Africa/Niamey" },
|
|
||||||
{ label: "Nouakchott", value: "Africa/Nouakchott" },
|
|
||||||
{ label: "Ouagadougou", value: "Africa/Ouagadougou" },
|
|
||||||
{ label: "Porto-Novo", value: "Africa/Porto-Novo" },
|
|
||||||
{ label: "Sao Tome", value: "Africa/Sao_Tome" },
|
|
||||||
{ label: "Tripoli", value: "Africa/Tripoli" },
|
|
||||||
{ label: "Tunis", value: "Africa/Tunis" },
|
|
||||||
{ label: "Windhoek", value: "Africa/Windhoek" },
|
|
||||||
],
|
|
||||||
America: [
|
|
||||||
{ label: "Adak", value: "America/Adak" },
|
|
||||||
{ label: "Anchorage", value: "America/Anchorage" },
|
|
||||||
{ label: "Anguilla", value: "America/Anguilla" },
|
|
||||||
{ label: "Antigua", value: "America/Antigua" },
|
|
||||||
{ label: "Araguaina", value: "America/Araguaina" },
|
|
||||||
{
|
|
||||||
label: "Argentina/Buenos Aires",
|
|
||||||
value: "America/Argentina/Buenos_Aires",
|
|
||||||
},
|
|
||||||
{ label: "Argentina/Catamarca", value: "America/Argentina/Catamarca" },
|
|
||||||
{ label: "Argentina/Cordoba", value: "America/Argentina/Cordoba" },
|
|
||||||
{ label: "Argentina/Jujuy", value: "America/Argentina/Jujuy" },
|
|
||||||
{ label: "Argentina/La Rioja", value: "America/Argentina/La_Rioja" },
|
|
||||||
{ label: "Argentina/Mendoza", value: "America/Argentina/Mendoza" },
|
|
||||||
{
|
|
||||||
label: "Argentina/Rio Gallegos",
|
|
||||||
value: "America/Argentina/Rio_Gallegos",
|
|
||||||
},
|
|
||||||
{ label: "Argentina/Salta", value: "America/Argentina/Salta" },
|
|
||||||
{ label: "Argentina/San Juan", value: "America/Argentina/San_Juan" },
|
|
||||||
{ label: "Argentina/San Luis", value: "America/Argentina/San_Luis" },
|
|
||||||
{ label: "Argentina/Tucuman", value: "America/Argentina/Tucuman" },
|
|
||||||
{ label: "Argentina/Ushuaia", value: "America/Argentina/Ushuaia" },
|
|
||||||
{ label: "Aruba", value: "America/Aruba" },
|
|
||||||
{ label: "Asuncion", value: "America/Asuncion" },
|
|
||||||
{ label: "Atikokan", value: "America/Atikokan" },
|
|
||||||
{ label: "Bahia", value: "America/Bahia" },
|
|
||||||
{ label: "Bahia Banderas", value: "America/Bahia_Banderas" },
|
|
||||||
{ label: "Barbados", value: "America/Barbados" },
|
|
||||||
{ label: "Belem", value: "America/Belem" },
|
|
||||||
{ label: "Belize", value: "America/Belize" },
|
|
||||||
{ label: "Blanc-Sablon", value: "America/Blanc-Sablon" },
|
|
||||||
{ label: "Boa Vista", value: "America/Boa_Vista" },
|
|
||||||
{ label: "Bogota", value: "America/Bogota" },
|
|
||||||
{ label: "Boise", value: "America/Boise" },
|
|
||||||
{ label: "Cambridge Bay", value: "America/Cambridge_Bay" },
|
|
||||||
{ label: "Campo Grande", value: "America/Campo_Grande" },
|
|
||||||
{ label: "Cancun", value: "America/Cancun" },
|
|
||||||
{ label: "Caracas", value: "America/Caracas" },
|
|
||||||
{ label: "Cayenne", value: "America/Cayenne" },
|
|
||||||
{ label: "Cayman", value: "America/Cayman" },
|
|
||||||
{ label: "Chicago (Central Time)", value: "America/Chicago" },
|
|
||||||
{ label: "Chihuahua", value: "America/Chihuahua" },
|
|
||||||
{ label: "Ciudad Juarez", value: "America/Ciudad_Juarez" },
|
|
||||||
{ label: "Costa Rica", value: "America/Costa_Rica" },
|
|
||||||
{ label: "Creston", value: "America/Creston" },
|
|
||||||
{ label: "Cuiaba", value: "America/Cuiaba" },
|
|
||||||
{ label: "Curacao", value: "America/Curacao" },
|
|
||||||
{ label: "Danmarkshavn", value: "America/Danmarkshavn" },
|
|
||||||
{ label: "Dawson", value: "America/Dawson" },
|
|
||||||
{ label: "Dawson Creek", value: "America/Dawson_Creek" },
|
|
||||||
{ label: "Denver (Mountain Time)", value: "America/Denver" },
|
|
||||||
{ label: "Detroit", value: "America/Detroit" },
|
|
||||||
{ label: "Dominica", value: "America/Dominica" },
|
|
||||||
{ label: "Edmonton", value: "America/Edmonton" },
|
|
||||||
{ label: "Eirunepe", value: "America/Eirunepe" },
|
|
||||||
{ label: "El Salvador", value: "America/El_Salvador" },
|
|
||||||
{ label: "Fort Nelson", value: "America/Fort_Nelson" },
|
|
||||||
{ label: "Fortaleza", value: "America/Fortaleza" },
|
|
||||||
{ label: "Glace Bay", value: "America/Glace_Bay" },
|
|
||||||
{ label: "Goose Bay", value: "America/Goose_Bay" },
|
|
||||||
{ label: "Grand Turk", value: "America/Grand_Turk" },
|
|
||||||
{ label: "Grenada", value: "America/Grenada" },
|
|
||||||
{ label: "Guadeloupe", value: "America/Guadeloupe" },
|
|
||||||
{ label: "Guatemala", value: "America/Guatemala" },
|
|
||||||
{ label: "Guayaquil", value: "America/Guayaquil" },
|
|
||||||
{ label: "Guyana", value: "America/Guyana" },
|
|
||||||
{ label: "Halifax", value: "America/Halifax" },
|
|
||||||
{ label: "Havana", value: "America/Havana" },
|
|
||||||
{ label: "Hermosillo", value: "America/Hermosillo" },
|
|
||||||
{ label: "Indiana/Indianapolis", value: "America/Indiana/Indianapolis" },
|
|
||||||
{ label: "Indiana/Knox", value: "America/Indiana/Knox" },
|
|
||||||
{ label: "Indiana/Marengo", value: "America/Indiana/Marengo" },
|
|
||||||
{ label: "Indiana/Petersburg", value: "America/Indiana/Petersburg" },
|
|
||||||
{ label: "Indiana/Tell City", value: "America/Indiana/Tell_City" },
|
|
||||||
{ label: "Indiana/Vevay", value: "America/Indiana/Vevay" },
|
|
||||||
{ label: "Indiana/Vincennes", value: "America/Indiana/Vincennes" },
|
|
||||||
{ label: "Indiana/Winamac", value: "America/Indiana/Winamac" },
|
|
||||||
{ label: "Inuvik", value: "America/Inuvik" },
|
|
||||||
{ label: "Iqaluit", value: "America/Iqaluit" },
|
|
||||||
{ label: "Jamaica", value: "America/Jamaica" },
|
|
||||||
{ label: "Juneau", value: "America/Juneau" },
|
|
||||||
{ label: "Kentucky/Louisville", value: "America/Kentucky/Louisville" },
|
|
||||||
{ label: "Kentucky/Monticello", value: "America/Kentucky/Monticello" },
|
|
||||||
{ label: "Kralendijk", value: "America/Kralendijk" },
|
|
||||||
{ label: "La Paz", value: "America/La_Paz" },
|
|
||||||
{ label: "Lima", value: "America/Lima" },
|
|
||||||
{ label: "Los Angeles (Pacific Time)", value: "America/Los_Angeles" },
|
|
||||||
{ label: "Lower Princes", value: "America/Lower_Princes" },
|
|
||||||
{ label: "Maceio", value: "America/Maceio" },
|
|
||||||
{ label: "Managua", value: "America/Managua" },
|
|
||||||
{ label: "Manaus", value: "America/Manaus" },
|
|
||||||
{ label: "Marigot", value: "America/Marigot" },
|
|
||||||
{ label: "Martinique", value: "America/Martinique" },
|
|
||||||
{ label: "Matamoros", value: "America/Matamoros" },
|
|
||||||
{ label: "Mazatlan", value: "America/Mazatlan" },
|
|
||||||
{ label: "Menominee", value: "America/Menominee" },
|
|
||||||
{ label: "Merida", value: "America/Merida" },
|
|
||||||
{ label: "Metlakatla", value: "America/Metlakatla" },
|
|
||||||
{ label: "Mexico City (Central Mexico)", value: "America/Mexico_City" },
|
|
||||||
{ label: "Miquelon", value: "America/Miquelon" },
|
|
||||||
{ label: "Moncton", value: "America/Moncton" },
|
|
||||||
{ label: "Monterrey", value: "America/Monterrey" },
|
|
||||||
{ label: "Montevideo", value: "America/Montevideo" },
|
|
||||||
{ label: "Montserrat", value: "America/Montserrat" },
|
|
||||||
{ label: "Nassau", value: "America/Nassau" },
|
|
||||||
{ label: "New York (Eastern Time)", value: "America/New_York" },
|
|
||||||
{ label: "Nome", value: "America/Nome" },
|
|
||||||
{ label: "Noronha", value: "America/Noronha" },
|
|
||||||
{ label: "North Dakota/Beulah", value: "America/North_Dakota/Beulah" },
|
|
||||||
{ label: "North Dakota/Center", value: "America/North_Dakota/Center" },
|
|
||||||
{
|
|
||||||
label: "North Dakota/New Salem",
|
|
||||||
value: "America/North_Dakota/New_Salem",
|
|
||||||
},
|
|
||||||
{ label: "Nuuk", value: "America/Nuuk" },
|
|
||||||
{ label: "Ojinaga", value: "America/Ojinaga" },
|
|
||||||
{ label: "Panama", value: "America/Panama" },
|
|
||||||
{ label: "Paramaribo", value: "America/Paramaribo" },
|
|
||||||
{ label: "Phoenix", value: "America/Phoenix" },
|
|
||||||
{ label: "Port-au-Prince", value: "America/Port-au-Prince" },
|
|
||||||
{ label: "Port of Spain", value: "America/Port_of_Spain" },
|
|
||||||
{ label: "Porto Velho", value: "America/Porto_Velho" },
|
|
||||||
{ label: "Puerto Rico", value: "America/Puerto_Rico" },
|
|
||||||
{ label: "Punta Arenas", value: "America/Punta_Arenas" },
|
|
||||||
{ label: "Rankin Inlet", value: "America/Rankin_Inlet" },
|
|
||||||
{ label: "Recife", value: "America/Recife" },
|
|
||||||
{ label: "Regina", value: "America/Regina" },
|
|
||||||
{ label: "Resolute", value: "America/Resolute" },
|
|
||||||
{ label: "Rio Branco", value: "America/Rio_Branco" },
|
|
||||||
{ label: "Santarem", value: "America/Santarem" },
|
|
||||||
{ label: "Santiago", value: "America/Santiago" },
|
|
||||||
{ label: "Santo Domingo", value: "America/Santo_Domingo" },
|
|
||||||
{ label: "Sao Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
|
|
||||||
{ label: "Scoresbysund", value: "America/Scoresbysund" },
|
|
||||||
{ label: "Sitka", value: "America/Sitka" },
|
|
||||||
{ label: "St Barthelemy", value: "America/St_Barthelemy" },
|
|
||||||
{ label: "St Johns", value: "America/St_Johns" },
|
|
||||||
{ label: "St Kitts", value: "America/St_Kitts" },
|
|
||||||
{ label: "St Lucia", value: "America/St_Lucia" },
|
|
||||||
{ label: "St Thomas", value: "America/St_Thomas" },
|
|
||||||
{ label: "St Vincent", value: "America/St_Vincent" },
|
|
||||||
{ label: "Swift Current", value: "America/Swift_Current" },
|
|
||||||
{ label: "Tegucigalpa", value: "America/Tegucigalpa" },
|
|
||||||
{ label: "Thule", value: "America/Thule" },
|
|
||||||
{ label: "Tijuana", value: "America/Tijuana" },
|
|
||||||
{ label: "Toronto", value: "America/Toronto" },
|
|
||||||
{ label: "Tortola", value: "America/Tortola" },
|
|
||||||
{ label: "Vancouver", value: "America/Vancouver" },
|
|
||||||
{ label: "Whitehorse", value: "America/Whitehorse" },
|
|
||||||
{ label: "Winnipeg", value: "America/Winnipeg" },
|
|
||||||
{ label: "Yakutat", value: "America/Yakutat" },
|
|
||||||
],
|
|
||||||
Antarctica: [
|
|
||||||
{ label: "Casey", value: "Antarctica/Casey" },
|
|
||||||
{ label: "Davis", value: "Antarctica/Davis" },
|
|
||||||
{ label: "DumontDUrville", value: "Antarctica/DumontDUrville" },
|
|
||||||
{ label: "Macquarie", value: "Antarctica/Macquarie" },
|
|
||||||
{ label: "Mawson", value: "Antarctica/Mawson" },
|
|
||||||
{ label: "McMurdo", value: "Antarctica/McMurdo" },
|
|
||||||
{ label: "Palmer", value: "Antarctica/Palmer" },
|
|
||||||
{ label: "Rothera", value: "Antarctica/Rothera" },
|
|
||||||
{ label: "Syowa", value: "Antarctica/Syowa" },
|
|
||||||
{ label: "Troll", value: "Antarctica/Troll" },
|
|
||||||
{ label: "Vostok", value: "Antarctica/Vostok" },
|
|
||||||
],
|
|
||||||
Arctic: [{ label: "Longyearbyen", value: "Arctic/Longyearbyen" }],
|
|
||||||
Asia: [
|
|
||||||
{ label: "Aden", value: "Asia/Aden" },
|
|
||||||
{ label: "Almaty", value: "Asia/Almaty" },
|
|
||||||
{ label: "Amman", value: "Asia/Amman" },
|
|
||||||
{ label: "Anadyr", value: "Asia/Anadyr" },
|
|
||||||
{ label: "Aqtau", value: "Asia/Aqtau" },
|
|
||||||
{ label: "Aqtobe", value: "Asia/Aqtobe" },
|
|
||||||
{ label: "Ashgabat", value: "Asia/Ashgabat" },
|
|
||||||
{ label: "Atyrau", value: "Asia/Atyrau" },
|
|
||||||
{ label: "Baghdad", value: "Asia/Baghdad" },
|
|
||||||
{ label: "Bahrain", value: "Asia/Bahrain" },
|
|
||||||
{ label: "Baku", value: "Asia/Baku" },
|
|
||||||
{ label: "Bangkok", value: "Asia/Bangkok" },
|
|
||||||
{ label: "Barnaul", value: "Asia/Barnaul" },
|
|
||||||
{ label: "Beirut", value: "Asia/Beirut" },
|
|
||||||
{ label: "Bishkek", value: "Asia/Bishkek" },
|
|
||||||
{ label: "Brunei", value: "Asia/Brunei" },
|
|
||||||
{ label: "Chita", value: "Asia/Chita" },
|
|
||||||
{ label: "Choibalsan", value: "Asia/Choibalsan" },
|
|
||||||
{ label: "Colombo", value: "Asia/Colombo" },
|
|
||||||
{ label: "Damascus", value: "Asia/Damascus" },
|
|
||||||
{ label: "Dhaka", value: "Asia/Dhaka" },
|
|
||||||
{ label: "Dili", value: "Asia/Dili" },
|
|
||||||
{ label: "Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
|
|
||||||
{ label: "Dushanbe", value: "Asia/Dushanbe" },
|
|
||||||
{ label: "Famagusta", value: "Asia/Famagusta" },
|
|
||||||
{ label: "Gaza", value: "Asia/Gaza" },
|
|
||||||
{ label: "Hebron", value: "Asia/Hebron" },
|
|
||||||
{ label: "Ho Chi Minh", value: "Asia/Ho_Chi_Minh" },
|
|
||||||
{ label: "Hong Kong", value: "Asia/Hong_Kong" },
|
|
||||||
{ label: "Hovd", value: "Asia/Hovd" },
|
|
||||||
{ label: "Irkutsk", value: "Asia/Irkutsk" },
|
|
||||||
{ label: "Jakarta", value: "Asia/Jakarta" },
|
|
||||||
{ label: "Jayapura", value: "Asia/Jayapura" },
|
|
||||||
{ label: "Jerusalem", value: "Asia/Jerusalem" },
|
|
||||||
{ label: "Kabul", value: "Asia/Kabul" },
|
|
||||||
{ label: "Kamchatka", value: "Asia/Kamchatka" },
|
|
||||||
{ label: "Karachi", value: "Asia/Karachi" },
|
|
||||||
{ label: "Kathmandu", value: "Asia/Kathmandu" },
|
|
||||||
{ label: "Khandyga", value: "Asia/Khandyga" },
|
|
||||||
{ label: "Kolkata (India Standard Time)", value: "Asia/Kolkata" },
|
|
||||||
{ label: "Krasnoyarsk", value: "Asia/Krasnoyarsk" },
|
|
||||||
{ label: "Kuala Lumpur", value: "Asia/Kuala_Lumpur" },
|
|
||||||
{ label: "Kuching", value: "Asia/Kuching" },
|
|
||||||
{ label: "Kuwait", value: "Asia/Kuwait" },
|
|
||||||
{ label: "Macau", value: "Asia/Macau" },
|
|
||||||
{ label: "Magadan", value: "Asia/Magadan" },
|
|
||||||
{ label: "Makassar", value: "Asia/Makassar" },
|
|
||||||
{ label: "Manila", value: "Asia/Manila" },
|
|
||||||
{ label: "Muscat", value: "Asia/Muscat" },
|
|
||||||
{ label: "Nicosia", value: "Asia/Nicosia" },
|
|
||||||
{ label: "Novokuznetsk", value: "Asia/Novokuznetsk" },
|
|
||||||
{ label: "Novosibirsk", value: "Asia/Novosibirsk" },
|
|
||||||
{ label: "Omsk", value: "Asia/Omsk" },
|
|
||||||
{ label: "Oral", value: "Asia/Oral" },
|
|
||||||
{ label: "Phnom Penh", value: "Asia/Phnom_Penh" },
|
|
||||||
{ label: "Pontianak", value: "Asia/Pontianak" },
|
|
||||||
{ label: "Pyongyang", value: "Asia/Pyongyang" },
|
|
||||||
{ label: "Qatar", value: "Asia/Qatar" },
|
|
||||||
{ label: "Qostanay", value: "Asia/Qostanay" },
|
|
||||||
{ label: "Qyzylorda", value: "Asia/Qyzylorda" },
|
|
||||||
{ label: "Riyadh", value: "Asia/Riyadh" },
|
|
||||||
{ label: "Sakhalin", value: "Asia/Sakhalin" },
|
|
||||||
{ label: "Samarkand", value: "Asia/Samarkand" },
|
|
||||||
{ label: "Seoul", value: "Asia/Seoul" },
|
|
||||||
{ label: "Shanghai (China Standard Time)", value: "Asia/Shanghai" },
|
|
||||||
{ label: "Singapore", value: "Asia/Singapore" },
|
|
||||||
{ label: "Srednekolymsk", value: "Asia/Srednekolymsk" },
|
|
||||||
{ label: "Taipei", value: "Asia/Taipei" },
|
|
||||||
{ label: "Tashkent", value: "Asia/Tashkent" },
|
|
||||||
{ label: "Tbilisi", value: "Asia/Tbilisi" },
|
|
||||||
{ label: "Tehran", value: "Asia/Tehran" },
|
|
||||||
{ label: "Thimphu", value: "Asia/Thimphu" },
|
|
||||||
{ label: "Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
|
|
||||||
{ label: "Tomsk", value: "Asia/Tomsk" },
|
|
||||||
{ label: "Ulaanbaatar", value: "Asia/Ulaanbaatar" },
|
|
||||||
{ label: "Urumqi", value: "Asia/Urumqi" },
|
|
||||||
{ label: "Ust-Nera", value: "Asia/Ust-Nera" },
|
|
||||||
{ label: "Vientiane", value: "Asia/Vientiane" },
|
|
||||||
{ label: "Vladivostok", value: "Asia/Vladivostok" },
|
|
||||||
{ label: "Yakutsk", value: "Asia/Yakutsk" },
|
|
||||||
{ label: "Yangon", value: "Asia/Yangon" },
|
|
||||||
{ label: "Yekaterinburg", value: "Asia/Yekaterinburg" },
|
|
||||||
{ label: "Yerevan", value: "Asia/Yerevan" },
|
|
||||||
],
|
|
||||||
Atlantic: [
|
|
||||||
{ label: "Azores", value: "Atlantic/Azores" },
|
|
||||||
{ label: "Bermuda", value: "Atlantic/Bermuda" },
|
|
||||||
{ label: "Canary", value: "Atlantic/Canary" },
|
|
||||||
{ label: "Cape Verde", value: "Atlantic/Cape_Verde" },
|
|
||||||
{ label: "Faroe", value: "Atlantic/Faroe" },
|
|
||||||
{ label: "Madeira", value: "Atlantic/Madeira" },
|
|
||||||
{ label: "Reykjavik", value: "Atlantic/Reykjavik" },
|
|
||||||
{ label: "South Georgia", value: "Atlantic/South_Georgia" },
|
|
||||||
{ label: "St Helena", value: "Atlantic/St_Helena" },
|
|
||||||
{ label: "Stanley", value: "Atlantic/Stanley" },
|
|
||||||
],
|
|
||||||
Australia: [
|
|
||||||
{ label: "Adelaide", value: "Australia/Adelaide" },
|
|
||||||
{ label: "Brisbane", value: "Australia/Brisbane" },
|
|
||||||
{ label: "Broken Hill", value: "Australia/Broken_Hill" },
|
|
||||||
{ label: "Darwin", value: "Australia/Darwin" },
|
|
||||||
{ label: "Eucla", value: "Australia/Eucla" },
|
|
||||||
{ label: "Hobart", value: "Australia/Hobart" },
|
|
||||||
{ label: "Lindeman", value: "Australia/Lindeman" },
|
|
||||||
{ label: "Lord Howe", value: "Australia/Lord_Howe" },
|
|
||||||
{ label: "Melbourne", value: "Australia/Melbourne" },
|
|
||||||
{ label: "Perth", value: "Australia/Perth" },
|
|
||||||
{ label: "Sydney (Australian Eastern Time)", value: "Australia/Sydney" },
|
|
||||||
],
|
|
||||||
Europe: [
|
|
||||||
{ label: "Amsterdam", value: "Europe/Amsterdam" },
|
|
||||||
{ label: "Andorra", value: "Europe/Andorra" },
|
|
||||||
{ label: "Astrakhan", value: "Europe/Astrakhan" },
|
|
||||||
{ label: "Athens", value: "Europe/Athens" },
|
|
||||||
{ label: "Belgrade", value: "Europe/Belgrade" },
|
|
||||||
{ label: "Berlin (Central European Time)", value: "Europe/Berlin" },
|
|
||||||
{ label: "Bratislava", value: "Europe/Bratislava" },
|
|
||||||
{ label: "Brussels", value: "Europe/Brussels" },
|
|
||||||
{ label: "Bucharest", value: "Europe/Bucharest" },
|
|
||||||
{ label: "Budapest", value: "Europe/Budapest" },
|
|
||||||
{ label: "Busingen", value: "Europe/Busingen" },
|
|
||||||
{ label: "Chisinau", value: "Europe/Chisinau" },
|
|
||||||
{ label: "Copenhagen", value: "Europe/Copenhagen" },
|
|
||||||
{ label: "Dublin", value: "Europe/Dublin" },
|
|
||||||
{ label: "Gibraltar", value: "Europe/Gibraltar" },
|
|
||||||
{ label: "Guernsey", value: "Europe/Guernsey" },
|
|
||||||
{ label: "Helsinki", value: "Europe/Helsinki" },
|
|
||||||
{ label: "Isle of Man", value: "Europe/Isle_of_Man" },
|
|
||||||
{ label: "Istanbul", value: "Europe/Istanbul" },
|
|
||||||
{ label: "Jersey", value: "Europe/Jersey" },
|
|
||||||
{ label: "Kaliningrad", value: "Europe/Kaliningrad" },
|
|
||||||
{ label: "Kirov", value: "Europe/Kirov" },
|
|
||||||
{ label: "Kyiv", value: "Europe/Kyiv" },
|
|
||||||
{ label: "Lisbon", value: "Europe/Lisbon" },
|
|
||||||
{ label: "Ljubljana", value: "Europe/Ljubljana" },
|
|
||||||
{ label: "London (Greenwich Mean Time)", value: "Europe/London" },
|
|
||||||
{ label: "Luxembourg", value: "Europe/Luxembourg" },
|
|
||||||
{ label: "Madrid", value: "Europe/Madrid" },
|
|
||||||
{ label: "Malta", value: "Europe/Malta" },
|
|
||||||
{ label: "Mariehamn", value: "Europe/Mariehamn" },
|
|
||||||
{ label: "Minsk", value: "Europe/Minsk" },
|
|
||||||
{ label: "Monaco", value: "Europe/Monaco" },
|
|
||||||
{ label: "Moscow", value: "Europe/Moscow" },
|
|
||||||
{ label: "Oslo", value: "Europe/Oslo" },
|
|
||||||
{ label: "Paris (Central European Time)", value: "Europe/Paris" },
|
|
||||||
{ label: "Podgorica", value: "Europe/Podgorica" },
|
|
||||||
{ label: "Prague", value: "Europe/Prague" },
|
|
||||||
{ label: "Riga", value: "Europe/Riga" },
|
|
||||||
{ label: "Rome", value: "Europe/Rome" },
|
|
||||||
{ label: "Samara", value: "Europe/Samara" },
|
|
||||||
{ label: "San Marino", value: "Europe/San_Marino" },
|
|
||||||
{ label: "Sarajevo", value: "Europe/Sarajevo" },
|
|
||||||
{ label: "Saratov", value: "Europe/Saratov" },
|
|
||||||
{ label: "Simferopol", value: "Europe/Simferopol" },
|
|
||||||
{ label: "Skopje", value: "Europe/Skopje" },
|
|
||||||
{ label: "Sofia", value: "Europe/Sofia" },
|
|
||||||
{ label: "Stockholm", value: "Europe/Stockholm" },
|
|
||||||
{ label: "Tallinn", value: "Europe/Tallinn" },
|
|
||||||
{ label: "Tirane", value: "Europe/Tirane" },
|
|
||||||
{ label: "Ulyanovsk", value: "Europe/Ulyanovsk" },
|
|
||||||
{ label: "Vaduz", value: "Europe/Vaduz" },
|
|
||||||
{ label: "Vatican", value: "Europe/Vatican" },
|
|
||||||
{ label: "Vienna", value: "Europe/Vienna" },
|
|
||||||
{ label: "Vilnius", value: "Europe/Vilnius" },
|
|
||||||
{ label: "Volgograd", value: "Europe/Volgograd" },
|
|
||||||
{ label: "Warsaw", value: "Europe/Warsaw" },
|
|
||||||
{ label: "Zagreb", value: "Europe/Zagreb" },
|
|
||||||
{ label: "Zurich", value: "Europe/Zurich" },
|
|
||||||
],
|
|
||||||
Indian: [
|
|
||||||
{ label: "Antananarivo", value: "Indian/Antananarivo" },
|
|
||||||
{ label: "Chagos", value: "Indian/Chagos" },
|
|
||||||
{ label: "Christmas", value: "Indian/Christmas" },
|
|
||||||
{ label: "Cocos", value: "Indian/Cocos" },
|
|
||||||
{ label: "Comoro", value: "Indian/Comoro" },
|
|
||||||
{ label: "Kerguelen", value: "Indian/Kerguelen" },
|
|
||||||
{ label: "Mahe", value: "Indian/Mahe" },
|
|
||||||
{ label: "Maldives", value: "Indian/Maldives" },
|
|
||||||
{ label: "Mauritius", value: "Indian/Mauritius" },
|
|
||||||
{ label: "Mayotte", value: "Indian/Mayotte" },
|
|
||||||
{ label: "Reunion", value: "Indian/Reunion" },
|
|
||||||
],
|
|
||||||
Pacific: [
|
|
||||||
{ label: "Apia", value: "Pacific/Apia" },
|
|
||||||
{ label: "Auckland", value: "Pacific/Auckland" },
|
|
||||||
{ label: "Bougainville", value: "Pacific/Bougainville" },
|
|
||||||
{ label: "Chatham", value: "Pacific/Chatham" },
|
|
||||||
{ label: "Chuuk", value: "Pacific/Chuuk" },
|
|
||||||
{ label: "Easter", value: "Pacific/Easter" },
|
|
||||||
{ label: "Efate", value: "Pacific/Efate" },
|
|
||||||
{ label: "Fakaofo", value: "Pacific/Fakaofo" },
|
|
||||||
{ label: "Fiji", value: "Pacific/Fiji" },
|
|
||||||
{ label: "Funafuti", value: "Pacific/Funafuti" },
|
|
||||||
{ label: "Galapagos", value: "Pacific/Galapagos" },
|
|
||||||
{ label: "Gambier", value: "Pacific/Gambier" },
|
|
||||||
{ label: "Guadalcanal", value: "Pacific/Guadalcanal" },
|
|
||||||
{ label: "Guam", value: "Pacific/Guam" },
|
|
||||||
{ label: "Honolulu", value: "Pacific/Honolulu" },
|
|
||||||
{ label: "Kanton", value: "Pacific/Kanton" },
|
|
||||||
{ label: "Kiritimati", value: "Pacific/Kiritimati" },
|
|
||||||
{ label: "Kosrae", value: "Pacific/Kosrae" },
|
|
||||||
{ label: "Kwajalein", value: "Pacific/Kwajalein" },
|
|
||||||
{ label: "Majuro", value: "Pacific/Majuro" },
|
|
||||||
{ label: "Marquesas", value: "Pacific/Marquesas" },
|
|
||||||
{ label: "Midway", value: "Pacific/Midway" },
|
|
||||||
{ label: "Nauru", value: "Pacific/Nauru" },
|
|
||||||
{ label: "Niue", value: "Pacific/Niue" },
|
|
||||||
{ label: "Norfolk", value: "Pacific/Norfolk" },
|
|
||||||
{ label: "Noumea", value: "Pacific/Noumea" },
|
|
||||||
{ label: "Pago Pago", value: "Pacific/Pago_Pago" },
|
|
||||||
{ label: "Palau", value: "Pacific/Palau" },
|
|
||||||
{ label: "Pitcairn", value: "Pacific/Pitcairn" },
|
|
||||||
{ label: "Pohnpei", value: "Pacific/Pohnpei" },
|
|
||||||
{ label: "Port Moresby", value: "Pacific/Port_Moresby" },
|
|
||||||
{ label: "Rarotonga", value: "Pacific/Rarotonga" },
|
|
||||||
{ label: "Saipan", value: "Pacific/Saipan" },
|
|
||||||
{ label: "Tahiti", value: "Pacific/Tahiti" },
|
|
||||||
{ label: "Tarawa", value: "Pacific/Tarawa" },
|
|
||||||
{ label: "Tongatapu", value: "Pacific/Tongatapu" },
|
|
||||||
{ label: "Wake", value: "Pacific/Wake" },
|
|
||||||
{ label: "Wallis", value: "Pacific/Wallis" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to get display label for a timezone value
|
|
||||||
export function getTimezoneLabel(value: string | undefined): string {
|
|
||||||
if (!value) return "UTC (default)";
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
|
import {
|
||||||
|
DatabaseZap,
|
||||||
|
Info,
|
||||||
|
PenBoxIcon,
|
||||||
|
PlusCircle,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -41,19 +47,13 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import type { CacheType } from "../domains/handle-domain";
|
import type { CacheType } from "../domains/handle-domain";
|
||||||
import { ScheduleFormField } from "../schedules/handle-schedules";
|
import { commonCronExpressions } from "../schedules/handle-schedules";
|
||||||
|
|
||||||
const formSchema = z
|
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
|
volumeName: z.string().min(1, "Volume name is required"),
|
||||||
.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()
|
||||||
@@ -306,9 +306,64 @@ export const HandleVolumeBackups = ({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ScheduleFormField
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
name="cronExpression"
|
name="cronExpression"
|
||||||
formControl={form.control}
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Schedule
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Cron expression format: minute hour day month
|
||||||
|
weekday
|
||||||
|
</p>
|
||||||
|
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</FormLabel>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a predefined schedule" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{commonCronExpressions.map((expr) => (
|
||||||
|
<SelectItem key={expr.value} value={expr.value}>
|
||||||
|
{expr.label} ({expr.value})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Choose a predefined schedule or enter a custom cron
|
||||||
|
expression
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -39,7 +38,6 @@ export const ShowVolumeBackups = ({
|
|||||||
type = "application",
|
type = "application",
|
||||||
serverId,
|
serverId,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [runningBackups, setRunningBackups] = useState<Set<string>>(new Set());
|
|
||||||
const {
|
const {
|
||||||
data: volumeBackups,
|
data: volumeBackups,
|
||||||
isLoading: isLoadingVolumeBackups,
|
isLoading: isLoadingVolumeBackups,
|
||||||
@@ -53,46 +51,34 @@ export const ShowVolumeBackups = ({
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
|
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
|
||||||
api.volumeBackups.delete.useMutation();
|
api.volumeBackups.delete.useMutation();
|
||||||
const { mutateAsync: runManually } =
|
|
||||||
api.volumeBackups.runManually.useMutation();
|
|
||||||
|
|
||||||
const handleRunManually = async (volumeBackupId: string) => {
|
const { mutateAsync: runManually, isLoading } =
|
||||||
setRunningBackups((prev) => new Set(prev).add(volumeBackupId));
|
api.volumeBackups.runManually.useMutation();
|
||||||
try {
|
|
||||||
await runManually({ volumeBackupId });
|
|
||||||
toast.success("Volume backup run successfully");
|
|
||||||
await refetchVolumeBackups();
|
|
||||||
} catch {
|
|
||||||
toast.error("Error running volume backup");
|
|
||||||
} finally {
|
|
||||||
setRunningBackups((prev) => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(volumeBackupId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||||
<CardHeader className="px-0">
|
<CardHeader className="px-0">
|
||||||
<div className="flex justify-between items-center flex-wrap gap-2">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
Volume Backups
|
Volume Backups
|
||||||
</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">
|
||||||
{volumeBackups && volumeBackups.length > 0 && (
|
{volumeBackups && volumeBackups.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RestoreVolumeBackups
|
<RestoreVolumeBackups
|
||||||
id={id}
|
id={id}
|
||||||
@@ -107,7 +93,7 @@ export const ShowVolumeBackups = ({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-0">
|
<CardContent className="px-0">
|
||||||
{isLoadingVolumeBackups ? (
|
{isLoadingVolumeBackups ? (
|
||||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
<span className="text-sm text-muted-foreground/70">
|
<span className="text-sm text-muted-foreground/70">
|
||||||
Loading volume backups...
|
Loading volume backups...
|
||||||
@@ -127,13 +113,13 @@ export const ShowVolumeBackups = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={volumeBackup.volumeBackupId}
|
key={volumeBackup.volumeBackupId}
|
||||||
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
|
className="flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 w-full sm:w-auto">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||||
<DatabaseBackup className="size-4 text-primary/70" />
|
<DatabaseBackup className="size-4 text-primary/70" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 w-full sm:w-auto">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="text-sm font-medium leading-none">
|
<h3 className="text-sm font-medium leading-none">
|
||||||
{volumeBackup.name}
|
{volumeBackup.name}
|
||||||
@@ -157,16 +143,18 @@ export const ShowVolumeBackups = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-2 sm:mt-0 sm:ml-3">
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<ShowDeploymentsModal
|
<ShowDeploymentsModal
|
||||||
id={volumeBackup.volumeBackupId}
|
id={volumeBackup.volumeBackupId}
|
||||||
type="volumeBackup"
|
type="volumeBackup"
|
||||||
serverId={serverId || undefined}
|
serverId={serverId || undefined}
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="icon">
|
<Button variant="ghost" size="icon">
|
||||||
<ClipboardList className="size-4 transition-colors" />
|
<ClipboardList className="size-4 transition-colors " />
|
||||||
</Button>
|
</Button>
|
||||||
</ShowDeploymentsModal>
|
</ShowDeploymentsModal>
|
||||||
|
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -174,18 +162,25 @@ export const ShowVolumeBackups = ({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
disabled={runningBackups.has(
|
isLoading={isLoading}
|
||||||
volumeBackup.volumeBackupId,
|
onClick={async () => {
|
||||||
)}
|
toast.success("Volume backup run successfully");
|
||||||
onClick={() =>
|
|
||||||
handleRunManually(volumeBackup.volumeBackupId)
|
await runManually({
|
||||||
}
|
volumeBackupId: volumeBackup.volumeBackupId,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, 1500),
|
||||||
|
);
|
||||||
|
refetchVolumeBackups();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error running volume backup");
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{runningBackups.has(volumeBackup.volumeBackupId) ? (
|
<Play className="size-4 transition-colors" />
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play className="size-4 transition-colors" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -193,11 +188,13 @@ export const ShowVolumeBackups = ({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<HandleVolumeBackups
|
<HandleVolumeBackups
|
||||||
volumeBackupId={volumeBackup.volumeBackupId}
|
volumeBackupId={volumeBackup.volumeBackupId}
|
||||||
id={id}
|
id={id}
|
||||||
volumeBackupType={type}
|
volumeBackupType={type}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete Volume Backup"
|
title="Delete Volume Backup"
|
||||||
description="Are you sure you want to delete this volume backup?"
|
description="Are you sure you want to delete this volume backup?"
|
||||||
@@ -221,7 +218,7 @@ export const ShowVolumeBackups = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-red-500/10"
|
className="group hover:bg-red-500/10 "
|
||||||
isLoading={isDeleting}
|
isLoading={isDeleting}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
@@ -233,7 +230,7 @@ export const ShowVolumeBackups = ({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||||
<DatabaseBackup className="size-8 mb-4 text-muted-foreground" />
|
<DatabaseBackup className="size-8 mb-4 text-muted-foreground" />
|
||||||
<p className="text-lg font-medium text-muted-foreground">
|
<p className="text-lg font-medium text-muted-foreground">
|
||||||
No volume backups
|
No volume backups
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
|||||||
push(
|
push(
|
||||||
`/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`,
|
`/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`,
|
||||||
);
|
);
|
||||||
toast.success("Service deleted successfully");
|
toast.success("deleted successfully");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -195,7 +195,6 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
<DockerTerminalModal
|
<DockerTerminalModal
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
appType={data?.composeType || "docker-compose"}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -35,7 +35,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
||||||
|
|
||||||
const form = useForm<AddComposeFile>({
|
const form = useForm<AddComposeFile>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -54,12 +53,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data?.composeFile !== undefined) {
|
|
||||||
setHasUnsavedChanges(composeFile !== data.composeFile);
|
|
||||||
}
|
|
||||||
}, [composeFile, data?.composeFile]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: AddComposeFile) => {
|
const onSubmit = async (data: AddComposeFile) => {
|
||||||
const { valid, error } = validateAndFormatYAML(data.composeFile);
|
const { valid, error } = validateAndFormatYAML(data.composeFile);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
@@ -74,12 +67,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
composeId,
|
composeId,
|
||||||
composeFile: data.composeFile,
|
composeFile: data.composeFile,
|
||||||
composePath: "./docker-compose.yml",
|
|
||||||
sourceType: "raw",
|
sourceType: "raw",
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Compose config Updated");
|
toast.success("Compose config Updated");
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
refetch();
|
refetch();
|
||||||
await utils.compose.getConvertedCompose.invalidate({
|
await utils.compose.getConvertedCompose.invalidate({
|
||||||
composeId,
|
composeId,
|
||||||
@@ -108,19 +99,6 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex flex-col gap-4 ">
|
<div className="w-full flex flex-col gap-4 ">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Compose File</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Configure your Docker Compose file for this service.
|
|
||||||
{hasUnsavedChanges && (
|
|
||||||
<span className="text-yellow-500 ml-2">
|
|
||||||
(You have unsaved changes)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
id="hook-form-save-compose-file"
|
id="hook-form-save-compose-file"
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
triggerType: data.triggerType,
|
triggerType: data.triggerType,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -97,16 +97,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const gitlabId = form.watch("gitlabId");
|
const gitlabId = form.watch("gitlabId");
|
||||||
|
|
||||||
const gitlabUrl = useMemo(() => {
|
|
||||||
const url = gitlabProviders?.find(
|
|
||||||
(provider) => provider.gitlabId === gitlabId,
|
|
||||||
)?.gitlabUrl;
|
|
||||||
|
|
||||||
const gitlabUrl = url?.replace(/\/$/, "");
|
|
||||||
|
|
||||||
return gitlabUrl || "https://gitlab.com";
|
|
||||||
}, [gitlabId, gitlabProviders]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: repositories,
|
data: repositories,
|
||||||
isLoading: isLoadingRepositories,
|
isLoading: isLoadingRepositories,
|
||||||
@@ -170,7 +160,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules,
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provided Saved");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -234,9 +224,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormItem className="md:col-span-2 flex flex-col">
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.gitlabPathNamespace && (
|
{field.value.owner && field.value.repo && (
|
||||||
<Link
|
<Link
|
||||||
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ interface Props {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
badgeStateColor;
|
||||||
|
|
||||||
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||||
const [option, setOption] = useState<"swarm" | "native">("native");
|
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||||
const [containerId, setContainerId] = useState<string | undefined>();
|
const [containerId, setContainerId] = useState<string | undefined>();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CheckIcon,
|
CheckIcon,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
DatabaseZap,
|
DatabaseZap,
|
||||||
|
Info,
|
||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -61,7 +62,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ScheduleFormField } from "../../application/schedules/handle-schedules";
|
import { commonCronExpressions } from "../../application/schedules/handle-schedules";
|
||||||
|
|
||||||
type CacheType = "cache" | "fetch";
|
type CacheType = "cache" | "fetch";
|
||||||
|
|
||||||
@@ -578,9 +579,66 @@ export const HandleBackup = ({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
<ScheduleFormField name="schedule" formControl={form.control} />
|
control={form.control}
|
||||||
|
name="schedule"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Schedule
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Cron expression format: minute hour day month
|
||||||
|
weekday
|
||||||
|
</p>
|
||||||
|
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</FormLabel>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a predefined schedule" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{commonCronExpressions.map((expr) => (
|
||||||
|
<SelectItem key={expr.value} value={expr.value}>
|
||||||
|
{expr.label} ({expr.value})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Choose a predefined schedule or enter a custom cron
|
||||||
|
expression
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="prefix"
|
name="prefix"
|
||||||
|
|||||||
@@ -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 _ from "lodash";
|
import { debounce } 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,12 +1,4 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import { Download as DownloadIcon, Loader2, Pause, Play } from "lucide-react";
|
||||||
import {
|
|
||||||
Check,
|
|
||||||
Copy,
|
|
||||||
Download as DownloadIcon,
|
|
||||||
Loader2,
|
|
||||||
Pause,
|
|
||||||
Play,
|
|
||||||
} from "lucide-react";
|
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
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";
|
||||||
@@ -75,7 +67,6 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
const isPausedRef = useRef(false);
|
const isPausedRef = useRef(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
const [copied, setCopied] = React.useState(false);
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (autoScroll && scrollRef.current) {
|
if (autoScroll && scrollRef.current) {
|
||||||
@@ -246,29 +237,6 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = async () => {
|
|
||||||
const logContent = filteredLogs
|
|
||||||
.map(
|
|
||||||
({
|
|
||||||
timestamp,
|
|
||||||
message,
|
|
||||||
}: {
|
|
||||||
timestamp: Date | null;
|
|
||||||
message: string;
|
|
||||||
}) =>
|
|
||||||
showTimestamp
|
|
||||||
? `${timestamp?.toISOString() || "No timestamp"} ${message}`
|
|
||||||
: message,
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const success = copy(logContent);
|
|
||||||
if (success) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilter = (logs: LogLine[]) => {
|
const handleFilter = (logs: LogLine[]) => {
|
||||||
return logs.filter((log) => {
|
return logs.filter((log) => {
|
||||||
const logType = getLogType(log.message).type;
|
const logType = getLogType(log.message).type;
|
||||||
@@ -352,21 +320,6 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
)}
|
)}
|
||||||
{isPaused ? "Resume" : "Pause"}
|
{isPaused ? "Resume" : "Pause"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-9"
|
|
||||||
onClick={handleCopy}
|
|
||||||
disabled={filteredLogs.length === 0}
|
|
||||||
title="Copy logs to clipboard"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<Check className="mr-2 h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
Copy
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FancyAnsi } from "fancy-ansi";
|
import { FancyAnsi } from "fancy-ansi";
|
||||||
import _ from "lodash";
|
import { escapeRegExp } 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,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function parseLogs(logString: string): LogLine[] {
|
|||||||
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
||||||
// message: "The server is running on port 8080" }
|
// message: "The server is running on port 8080" }
|
||||||
const logRegex =
|
const logRegex =
|
||||||
/^(?:(?<lineNumber>\d+)\s+)?(?<timestamp>(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC))?\s*(?<message>[\s\S]*)$/;
|
/^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/;
|
||||||
|
|
||||||
return logString
|
return logString
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] {
|
|||||||
const match = line.match(logRegex);
|
const match = line.match(logRegex);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
const { timestamp, message } = match.groups ?? {};
|
const [, , timestamp, message] = match;
|
||||||
|
|
||||||
if (!message?.trim()) return null;
|
if (!message?.trim()) return null;
|
||||||
|
|
||||||
@@ -108,8 +108,7 @@ export const getLogType = (message: string): LogStyle => {
|
|||||||
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
|
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
|
||||||
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
|
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
|
||||||
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
|
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
|
||||||
/\b(?:unstable|experimental)\b/i.test(lowerMessage) ||
|
/\b(?:unstable|experimental)\b/i.test(lowerMessage)
|
||||||
/⚠|⚠️/i.test(lowerMessage)
|
|
||||||
) {
|
) {
|
||||||
return LOG_STYLES.warning;
|
return LOG_STYLES.warning;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
containerId?: string;
|
containerId: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const addonFit = new FitAddon();
|
const addonFit = new FitAddon();
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
|
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
|
||||||
@@ -56,7 +57,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2 mt-4">
|
<div className="flex flex-col gap-2">
|
||||||
<span>
|
<span>
|
||||||
Select way to connect to <b>{containerId}</b>
|
Select way to connect to <b>{containerId}</b>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const ImpersonationBar = () => {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
toast.success("Successfully impersonating user", {
|
toast.success("Successfully impersonating user", {
|
||||||
description: `You are now viewing as ${`${selectedUser.name} ${selectedUser.lastName}`.trim() || selectedUser.email}`,
|
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
|
||||||
});
|
});
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -195,8 +195,7 @@ export const ImpersonationBar = () => {
|
|||||||
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||||
<span className="truncate flex flex-col items-start">
|
<span className="truncate flex flex-col items-start">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{`${selectedUser.name} ${selectedUser.lastName}`.trim() ||
|
{selectedUser.name || ""}
|
||||||
""}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{selectedUser.email}
|
{selectedUser.email}
|
||||||
@@ -243,8 +242,7 @@ export const ImpersonationBar = () => {
|
|||||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span className="flex flex-col items-start">
|
<span className="flex flex-col items-start">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{`${user.name} ${user.lastName}`.trim() ||
|
{user.name || ""}
|
||||||
""}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{user.email} • {user.role}
|
{user.email} • {user.role}
|
||||||
@@ -283,16 +281,11 @@ export const ImpersonationBar = () => {
|
|||||||
<div className="flex items-center gap-4 flex-1 flex-wrap">
|
<div className="flex items-center gap-4 flex-1 flex-wrap">
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
className="object-cover"
|
|
||||||
src={data?.user?.image || ""}
|
src={data?.user?.image || ""}
|
||||||
alt={
|
alt={data?.user?.name || ""}
|
||||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
|
||||||
""
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() ||
|
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
|
||||||
"U"}
|
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -305,8 +298,7 @@ export const ImpersonationBar = () => {
|
|||||||
Impersonating
|
Impersonating
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
{data?.user?.name || ""}
|
||||||
""}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DockerNetworkChart } from "./docker-network-chart";
|
|||||||
|
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
cpu: {
|
cpu: {
|
||||||
value: "0%",
|
value: 0,
|
||||||
time: "",
|
time: "",
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
@@ -46,7 +46,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export interface DockerStats {
|
export interface DockerStats {
|
||||||
cpu: {
|
cpu: {
|
||||||
value: string;
|
value: number;
|
||||||
time: string;
|
time: string;
|
||||||
};
|
};
|
||||||
memory: {
|
memory: {
|
||||||
@@ -220,13 +220,7 @@ export const ContainerFreeMonitoring = ({
|
|||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Used: {currentData.cpu.value}
|
Used: {currentData.cpu.value}
|
||||||
</span>
|
</span>
|
||||||
<Progress
|
<Progress value={currentData.cpu.value} className="w-[100%]" />
|
||||||
value={Number.parseInt(
|
|
||||||
currentData.cpu.value.replace("%", ""),
|
|
||||||
10,
|
|
||||||
)}
|
|
||||||
className="w-[100%]"
|
|
||||||
/>
|
|
||||||
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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 { useFieldArray, 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 { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -21,13 +20,6 @@ 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 {
|
||||||
@@ -69,25 +61,18 @@ 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]);
|
}, [data, form, form.reset]);
|
||||||
|
|
||||||
const onSubmit = async (formData: AddDockerImage) => {
|
const onSubmit = async (formData: AddDockerImage) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -98,7 +83,6 @@ 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");
|
||||||
@@ -144,68 +128,13 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Command</FormLabel>
|
<FormLabel>Command</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="/bin/sh" {...field} />
|
<Input placeholder="Custom command" {...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 || "";
|
const val = e.target.value?.trim() || "";
|
||||||
const serviceName = slugify(val.trim());
|
const serviceName = slugify(val);
|
||||||
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 || "";
|
const val = e.target.value?.trim() || "";
|
||||||
const serviceName = slugify(val.trim());
|
const serviceName = slugify(val);
|
||||||
form.setValue("appName", `${slug}-${serviceName}`);
|
form.setValue("appName", `${slug}-${serviceName}`);
|
||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import { api } from "@/utils/api";
|
|||||||
type DbType = typeof mySchema._type.type;
|
type DbType = typeof mySchema._type.type;
|
||||||
|
|
||||||
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||||
mongo: "mongo:7",
|
mongo: "mongo:6",
|
||||||
mariadb: "mariadb:11",
|
mariadb: "mariadb:11",
|
||||||
mysql: "mysql:8",
|
mysql: "mysql:8",
|
||||||
postgres: "postgres:15",
|
postgres: "postgres:15",
|
||||||
@@ -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 || "";
|
const val = e.target.value?.trim() || "";
|
||||||
const serviceName = slugify(val.trim());
|
const serviceName = slugify(val);
|
||||||
form.setValue("appName", `${slug}-${serviceName}`);
|
form.setValue("appName", `${slug}-${serviceName}`);
|
||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
}}
|
}}
|
||||||
@@ -559,7 +559,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="******************"
|
placeholder="******************"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
enablePasswordGenerator={true}
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -579,7 +578,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="******************"
|
placeholder="******************"
|
||||||
enablePasswordGenerator={true}
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="Search Template"
|
placeholder="Search Template"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="w-full"
|
className="w-full sm:w-[200px]"
|
||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
@@ -248,7 +248,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setViewMode(viewMode === "detailed" ? "icon" : "detailed")
|
setViewMode(viewMode === "detailed" ? "icon" : "detailed")
|
||||||
}
|
}
|
||||||
className="h-9 w-9 flex-shrink-0"
|
className="h-9 w-9"
|
||||||
>
|
>
|
||||||
{viewMode === "detailed" ? (
|
{viewMode === "detailed" ? (
|
||||||
<LayoutGrid className="size-4" />
|
<LayoutGrid className="size-4" />
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import type { findEnvironmentsByProjectId } from "@dokploy/server";
|
import type { findEnvironmentsByProjectId } from "@dokploy/server";
|
||||||
import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Terminal,
|
||||||
|
TrashIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
|
||||||
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 {
|
import {
|
||||||
@@ -56,20 +63,13 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
|
||||||
// Get current user's permissions
|
// API mutations
|
||||||
const { data: currentUser } = api.user.get.useQuery();
|
const { data: environment } = api.environment.one.useQuery(
|
||||||
|
{ environmentId: currentEnvironmentId || "" },
|
||||||
// Check if user can create environments
|
{
|
||||||
const canCreateEnvironments =
|
enabled: !!currentEnvironmentId,
|
||||||
currentUser?.role === "owner" ||
|
},
|
||||||
currentUser?.role === "admin" ||
|
);
|
||||||
currentUser?.canCreateEnvironments === true;
|
|
||||||
|
|
||||||
// Check if user can delete environments
|
|
||||||
const canDeleteEnvironments =
|
|
||||||
currentUser?.role === "owner" ||
|
|
||||||
currentUser?.role === "admin" ||
|
|
||||||
currentUser?.canDeleteEnvironments === true;
|
|
||||||
|
|
||||||
const haveServices =
|
const haveServices =
|
||||||
selectedEnvironment &&
|
selectedEnvironment &&
|
||||||
@@ -102,9 +102,7 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error("Failed to create environment");
|
||||||
`Failed to create environment: ${error instanceof Error ? error.message : error}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,9 +123,7 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(
|
toast.error("Failed to update environment");
|
||||||
`Failed to update environment: ${error instanceof Error ? error.message : error}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,18 +140,15 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
setSelectedEnvironment(null);
|
setSelectedEnvironment(null);
|
||||||
|
|
||||||
// Redirect to first available environment if we deleted the current environment
|
// Redirect to production if we deleted the current environment
|
||||||
if (selectedEnvironment.environmentId === currentEnvironmentId) {
|
if (selectedEnvironment.environmentId === currentEnvironmentId) {
|
||||||
const firstEnv = environments?.find(
|
const productionEnv = environments?.find(
|
||||||
(env) => env.environmentId !== selectedEnvironment.environmentId,
|
(env) => env.name === "production",
|
||||||
);
|
);
|
||||||
if (firstEnv) {
|
if (productionEnv) {
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/project/${projectId}/environment/${firstEnv.environmentId}`,
|
`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// No other environments, redirect to project page
|
|
||||||
router.push(`/dashboard/project/${projectId}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -246,8 +239,22 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<div className="flex items-center gap-1 px-2">
|
|
||||||
{!environment.isDefault && (
|
{/* Action buttons for non-production environments */}
|
||||||
|
<EnvironmentVariables environmentId={environment.environmentId}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Terminal className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
{environment.name !== "production" && (
|
||||||
|
<div className="flex items-center gap-1 px-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -259,8 +266,7 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
>
|
>
|
||||||
<PencilIcon className="h-3 w-3" />
|
<PencilIcon className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
{canDeleteEnvironments && !environment.isDefault && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -272,22 +278,20 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
>
|
>
|
||||||
<TrashIcon className="h-3 w-3" />
|
<TrashIcon className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{canCreateEnvironments && (
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
className="cursor-pointer"
|
||||||
className="cursor-pointer"
|
onClick={() => setIsCreateDialogOpen(true)}
|
||||||
onClick={() => setIsCreateDialogOpen(true)}
|
>
|
||||||
>
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
<PlusIcon className="h-4 w-4 mr-2" />
|
Create Environment
|
||||||
Create Environment
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const StepThree = ({ templateInfo }: StepProps) => {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold">Configuration Files</h3>
|
<h3 className="text-sm font-semibold">Configuration Files</h3>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
{templateInfo?.details?.configFiles?.map((file, index) => (
|
{templateInfo?.details?.configFiles.map((file, index) => (
|
||||||
<li key={index}>
|
<li key={index}>
|
||||||
<strong className="text-sm font-semibold">
|
<strong className="text-sm font-semibold">
|
||||||
{file.filePath}
|
{file.filePath}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Bot, PlusCircle, Trash2 } from "lucide-react";
|
import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
@@ -27,6 +27,7 @@ export interface StepProps {
|
|||||||
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
||||||
const suggestions = templateInfo.suggestions || [];
|
const suggestions = templateInfo.suggestions || [];
|
||||||
const selectedVariant = templateInfo.details;
|
const selectedVariant = templateInfo.details;
|
||||||
|
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } =
|
const { mutateAsync, isLoading, error, isError } =
|
||||||
api.ai.suggest.useMutation();
|
api.ai.suggest.useMutation();
|
||||||
@@ -43,7 +44,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
|||||||
.then((data) => {
|
.then((data) => {
|
||||||
setTemplateInfo({
|
setTemplateInfo({
|
||||||
...templateInfo,
|
...templateInfo,
|
||||||
suggestions: data || [],
|
suggestions: data,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -53,6 +54,10 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
|||||||
});
|
});
|
||||||
}, [templateInfo.userInput]);
|
}, [templateInfo.userInput]);
|
||||||
|
|
||||||
|
const toggleShowValue = (name: string) => {
|
||||||
|
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||||
|
};
|
||||||
|
|
||||||
const handleEnvVariableChange = (
|
const handleEnvVariableChange = (
|
||||||
index: number,
|
index: number,
|
||||||
field: "name" | "value",
|
field: "name" | "value",
|
||||||
@@ -303,9 +308,11 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
|||||||
placeholder="Variable Name"
|
placeholder="Variable Name"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<div className="relative">
|
<div className="flex-1 relative">
|
||||||
<Input
|
<Input
|
||||||
type={"password"}
|
type={
|
||||||
|
showValues[env.name] ? "text" : "password"
|
||||||
|
}
|
||||||
value={env.value}
|
value={env.value}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleEnvVariableChange(
|
handleEnvVariableChange(
|
||||||
@@ -316,6 +323,19 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
|||||||
}
|
}
|
||||||
placeholder="Variable Value"
|
placeholder="Variable Value"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||||
|
onClick={() => toggleShowValue(env.name)}
|
||||||
|
>
|
||||||
|
{showValues[env.name] ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -417,14 +437,13 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
|
|||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<ScrollArea className="w-full rounded-md border">
|
<ScrollArea className="w-full rounded-md border">
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
{selectedVariant?.configFiles?.length &&
|
{selectedVariant?.configFiles?.length > 0 ? (
|
||||||
selectedVariant?.configFiles?.length > 0 ? (
|
|
||||||
<>
|
<>
|
||||||
<div className="text-sm text-muted-foreground mb-4">
|
<div className="text-sm text-muted-foreground mb-4">
|
||||||
This template requires the following
|
This template requires the following
|
||||||
configuration files to be mounted:
|
configuration files to be mounted:
|
||||||
</div>
|
</div>
|
||||||
{selectedVariant?.configFiles?.map(
|
{selectedVariant.configFiles.map(
|
||||||
(config, index) => (
|
(config, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ interface Details {
|
|||||||
envVariables: EnvVariable[];
|
envVariables: EnvVariable[];
|
||||||
shortDescription: string;
|
shortDescription: string;
|
||||||
domains: Domain[];
|
domains: Domain[];
|
||||||
configFiles?: Mount[];
|
configFiles: Mount[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Mount {
|
interface Mount {
|
||||||
|
|||||||
@@ -80,29 +80,6 @@ export const DuplicateProject = ({
|
|||||||
api.project.duplicate.useMutation({
|
api.project.duplicate.useMutation({
|
||||||
onSuccess: async (newProject) => {
|
onSuccess: async (newProject) => {
|
||||||
await utils.project.all.invalidate();
|
await utils.project.all.invalidate();
|
||||||
|
|
||||||
// If duplicating to same project+environment, invalidate the environment query
|
|
||||||
// to refresh the services list
|
|
||||||
if (duplicateType === "existing-environment") {
|
|
||||||
await utils.environment.one.invalidate({
|
|
||||||
environmentId: selectedTargetEnvironment,
|
|
||||||
});
|
|
||||||
await utils.environment.byProjectId.invalidate({
|
|
||||||
projectId: selectedTargetProject,
|
|
||||||
});
|
|
||||||
|
|
||||||
// If duplicating to the same environment we're currently viewing,
|
|
||||||
// also invalidate the current environment to refresh the services list
|
|
||||||
if (selectedTargetEnvironment === environmentId) {
|
|
||||||
await utils.environment.one.invalidate({ environmentId });
|
|
||||||
// Also invalidate the project query to refresh the project data
|
|
||||||
const projectId = router.query.projectId as string;
|
|
||||||
if (projectId) {
|
|
||||||
await utils.project.one.invalidate({ projectId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
duplicateType === "new-project"
|
duplicateType === "new-project"
|
||||||
? "Project duplicated successfully"
|
? "Project duplicated successfully"
|
||||||
|
|||||||
@@ -82,21 +82,6 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
|||||||
.finally(() => {});
|
.finally(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
|
|
||||||
e.preventDefault();
|
|
||||||
form.handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [form, onSubmit, isLoading, isOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
|
|||||||
@@ -81,21 +81,6 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
|||||||
.finally(() => {});
|
.finally(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
|
|
||||||
e.preventDefault();
|
|
||||||
form.handleSubmit(onSubmit)();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
|
||||||
};
|
|
||||||
}, [form, onSubmit, isLoading, isOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user