mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-26 17:45:49 +02:00
Compare commits
2 Commits
core-model
...
feat/add-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1379b2118f | ||
|
|
794cd79973 |
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"
|
|
||||||
|
|
||||||
7
.gitignore
vendored
7
.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
|
||||||
|
|
||||||
@@ -43,7 +41,4 @@ yarn-error.log*
|
|||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
|
||||||
.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
|
||||||
@@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash
|
|||||||
RUN pnpm install -g tsx
|
RUN pnpm install -g tsx
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD [ "pnpm", "start" ]
|
CMD [ "pnpm", "start" ]
|
||||||
@@ -35,4 +35,4 @@ COPY --from=build /prod/schedules/dist ./dist
|
|||||||
COPY --from=build /prod/schedules/package.json ./package.json
|
COPY --from=build /prod/schedules/package.json ./package.json
|
||||||
COPY --from=build /prod/schedules/node_modules ./node_modules
|
COPY --from=build /prod/schedules/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||||
@@ -35,4 +35,4 @@ COPY --from=build /prod/api/dist ./dist
|
|||||||
COPY --from=build /prod/api/package.json ./package.json
|
COPY --from=build /prod/api/package.json ./package.json
|
||||||
COPY --from=build /prod/api/node_modules ./node_modules
|
COPY --from=build /prod/api/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||||
@@ -80,9 +80,7 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
|
<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" />
|
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
|
||||||
</a>
|
</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),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
deployPreviewApplication,
|
deployPreviewApplication,
|
||||||
rebuildApplication,
|
rebuildApplication,
|
||||||
rebuildCompose,
|
rebuildCompose,
|
||||||
rebuildPreviewApplication,
|
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -55,14 +54,7 @@ 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({
|
|
||||||
applicationId: job.applicationId,
|
|
||||||
titleLog: job.titleLog || "Rebuild Preview Deployment",
|
|
||||||
descriptionLog: job.descriptionLog || "",
|
|
||||||
previewDeploymentId: job.previewDeploymentId,
|
|
||||||
});
|
|
||||||
} else if (job.type === "deploy") {
|
|
||||||
await deployPreviewApplication({
|
await deployPreviewApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Preview Deployment",
|
titleLog: job.titleLog || "Preview Deployment",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
@@ -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,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,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,
|
|
||||||
);
|
|
||||||
@@ -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,9 +37,6 @@ 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",
|
||||||
@@ -68,7 +60,6 @@ const baseApp: ApplicationNested = {
|
|||||||
previewWildcard: "",
|
previewWildcard: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
isDefault: false,
|
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
|
|||||||
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é");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
809
apps/dokploy/__test__/queues/grouped-queue.test.ts
Normal file
809
apps/dokploy/__test__/queues/grouped-queue.test.ts
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { GroupedQueue } from "../../server/queues/grouped-queue-wrapper";
|
||||||
|
|
||||||
|
describe("GroupedQueue", () => {
|
||||||
|
describe("Basic functionality", () => {
|
||||||
|
it("should process a single job with concurrency 1", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await queue.add("group1", { id: "job1" });
|
||||||
|
|
||||||
|
// Wait for processing to complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect(processed).toEqual(["job1"]);
|
||||||
|
expect(queue.isIdle()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should process jobs in FIFO order within a group", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add multiple jobs to the same group
|
||||||
|
await Promise.all([
|
||||||
|
queue.add("group1", { id: "job1" }),
|
||||||
|
queue.add("group1", { id: "job2" }),
|
||||||
|
queue.add("group1", { id: "job3" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Wait for all processing
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
expect(processed).toEqual(["job1", "job2", "job3"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Concurrency 1 with multiple groups", () => {
|
||||||
|
it("should process one group at a time with concurrency 1", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string; group: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
const activeGroups: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
activeGroups.push(data.group);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
processed.push(data.id);
|
||||||
|
activeGroups.pop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs to 3 different groups
|
||||||
|
const promises = [
|
||||||
|
queue.add("app1", { id: "job1", group: "app1" }),
|
||||||
|
queue.add("app2", { id: "job2", group: "app2" }),
|
||||||
|
queue.add("app3", { id: "job3", group: "app3" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check after 30ms - only one should be processing
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
expect(activeGroups.length).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
// Wait for all to complete
|
||||||
|
await Promise.all(promises);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
expect(processed).toHaveLength(3);
|
||||||
|
expect(queue.isIdle()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should process groups sequentially with concurrency 1", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string; group: string }>(1);
|
||||||
|
const processingOrder: string[] = [];
|
||||||
|
const startTimes: Map<string, number> = new Map();
|
||||||
|
const endTimes: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
startTimes.set(data.id, Date.now());
|
||||||
|
processingOrder.push(`start-${data.group}`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
endTimes.set(data.id, Date.now());
|
||||||
|
processingOrder.push(`end-${data.group}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
queue.add("app1", { id: "job1", group: "app1" }),
|
||||||
|
queue.add("app2", { id: "job2", group: "app2" }),
|
||||||
|
queue.add("app3", { id: "job3", group: "app3" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// Verify sequential processing
|
||||||
|
expect(processingOrder).toEqual([
|
||||||
|
"start-app1",
|
||||||
|
"end-app1",
|
||||||
|
"start-app2",
|
||||||
|
"end-app2",
|
||||||
|
"start-app3",
|
||||||
|
"end-app3",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Verify jobs don't overlap
|
||||||
|
const job1End = endTimes.get("job1")!;
|
||||||
|
const job2Start = startTimes.get("job2")!;
|
||||||
|
const job2End = endTimes.get("job2")!;
|
||||||
|
const job3Start = startTimes.get("job3")!;
|
||||||
|
|
||||||
|
expect(job2Start).toBeGreaterThanOrEqual(job1End);
|
||||||
|
expect(job3Start).toBeGreaterThanOrEqual(job2End);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Concurrency 3 with 4 groups", () => {
|
||||||
|
it("should process up to 3 groups simultaneously", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string; group: string }>(3);
|
||||||
|
const activeGroups = new Set<string>();
|
||||||
|
const maxConcurrent = { value: 0 };
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
activeGroups.add(data.group);
|
||||||
|
maxConcurrent.value = Math.max(maxConcurrent.value, activeGroups.size);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
activeGroups.delete(data.group);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add 4 jobs to different groups
|
||||||
|
await Promise.all([
|
||||||
|
queue.add("app1", { id: "job1", group: "app1" }),
|
||||||
|
queue.add("app2", { id: "job2", group: "app2" }),
|
||||||
|
queue.add("app3", { id: "job3", group: "app3" }),
|
||||||
|
queue.add("app4", { id: "job4", group: "app4" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check during processing
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Should have processed 3 groups simultaneously
|
||||||
|
expect(maxConcurrent.value).toBe(3);
|
||||||
|
expect(activeGroups.size).toBeLessThanOrEqual(3);
|
||||||
|
|
||||||
|
// Wait for all to complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
expect(queue.isIdle()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should process 4th group after one of the first 3 completes", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string; group: string }>(3);
|
||||||
|
const processingOrder: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
processingOrder.push(`start-${data.group}`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
processingOrder.push(`end-${data.group}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
queue.add("app1", { id: "job1", group: "app1" }),
|
||||||
|
queue.add("app2", { id: "job2", group: "app2" }),
|
||||||
|
queue.add("app3", { id: "job3", group: "app3" }),
|
||||||
|
queue.add("app4", { id: "job4", group: "app4" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
|
||||||
|
// First 3 should start together
|
||||||
|
const firstThree = processingOrder.slice(0, 3);
|
||||||
|
expect(firstThree).toContain("start-app1");
|
||||||
|
expect(firstThree).toContain("start-app2");
|
||||||
|
expect(firstThree).toContain("start-app3");
|
||||||
|
|
||||||
|
// 4th should start after one completes
|
||||||
|
const app4StartIndex = processingOrder.indexOf("start-app4");
|
||||||
|
expect(app4StartIndex).toBeGreaterThan(0);
|
||||||
|
expect(app4StartIndex).toBeLessThan(processingOrder.length - 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple jobs per group", () => {
|
||||||
|
it("should process jobs sequentially within same group", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(3);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add 3 jobs to same group
|
||||||
|
await Promise.all([
|
||||||
|
queue.add("app1", { id: "job1" }),
|
||||||
|
queue.add("app1", { id: "job2" }),
|
||||||
|
queue.add("app1", { id: "job3" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Should process in order
|
||||||
|
expect(processed).toEqual(["job1", "job2", "job3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should process multiple groups with multiple jobs each", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string; group: string }>(2);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
processed.push(`${data.group}-${data.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs to 2 groups, 2 jobs each
|
||||||
|
await Promise.all([
|
||||||
|
queue.add("app1", { id: "job1", group: "app1" }),
|
||||||
|
queue.add("app1", { id: "job2", group: "app1" }),
|
||||||
|
queue.add("app2", { id: "job1", group: "app2" }),
|
||||||
|
queue.add("app2", { id: "job2", group: "app2" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Should process both groups, jobs within each group in order
|
||||||
|
expect(processed).toHaveLength(4);
|
||||||
|
expect(processed.filter((p) => p.startsWith("app1"))).toEqual([
|
||||||
|
"app1-job1",
|
||||||
|
"app1-job2",
|
||||||
|
]);
|
||||||
|
expect(processed.filter((p) => p.startsWith("app2"))).toEqual([
|
||||||
|
"app2-job1",
|
||||||
|
"app2-job2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error handling", () => {
|
||||||
|
it("should reject job on handler error", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
|
||||||
|
queue.setHandler(async () => {
|
||||||
|
throw new Error("Test error");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(queue.add("group1", { id: "job1" })).rejects.toThrow(
|
||||||
|
"Test error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should continue processing other jobs after error", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
if (data.id === "job2") {
|
||||||
|
throw new Error("Job 2 error");
|
||||||
|
}
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
queue.add("group1", { id: "job1" }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await expect(queue.add("group1", { id: "job2" })).rejects.toThrow();
|
||||||
|
await expect(
|
||||||
|
queue.add("group1", { id: "job3" }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect(processed).toEqual(["job1", "job3"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Queue management", () => {
|
||||||
|
it("should clear group tasks", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs without awaiting - they'll start processing
|
||||||
|
const job1Promise = queue.add("group1", { id: "job1" });
|
||||||
|
const job2Promise = queue.add("group1", { id: "job2" });
|
||||||
|
|
||||||
|
// Clear immediately - job1 might be processing, but job2 should be cleared
|
||||||
|
queue.clearGroup("group1");
|
||||||
|
|
||||||
|
// Use Promise.allSettled to handle both promises properly
|
||||||
|
const results = await Promise.allSettled([job1Promise, job2Promise]);
|
||||||
|
|
||||||
|
// job1 might succeed or fail depending on timing
|
||||||
|
// job2 should be rejected
|
||||||
|
const job2Result = results[1];
|
||||||
|
if (job2Result.status === "rejected") {
|
||||||
|
expect(job2Result.reason.message).toBe("Queue cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Job1 might have processed, but job2 should not
|
||||||
|
expect(processed.length).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct group length", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
|
||||||
|
queue.setHandler(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs without awaiting - check length immediately
|
||||||
|
const promises = [
|
||||||
|
queue.add("group1", { id: "job1" }),
|
||||||
|
queue.add("group1", { id: "job2" }),
|
||||||
|
queue.add("group1", { id: "job3" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check length immediately - at least some should be pending
|
||||||
|
// (job1 might be processing, but job2 and job3 should be pending)
|
||||||
|
const length = queue.getGroupLength("group1");
|
||||||
|
expect(length).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// Wait for all to complete
|
||||||
|
await Promise.all(promises);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// After processing should be 0
|
||||||
|
expect(queue.getGroupLength("group1")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should close queue and reject pending tasks", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
|
||||||
|
queue.setHandler(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add first job and wait a bit to ensure it starts processing
|
||||||
|
const job1Promise = queue.add("group1", { id: "job1" });
|
||||||
|
// Add second job without awaiting
|
||||||
|
const job2Promise = queue.add("group1", { id: "job2" });
|
||||||
|
|
||||||
|
// Wait a tiny bit to ensure job2 is queued
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Close queue - job2 should be rejected
|
||||||
|
await queue.close();
|
||||||
|
|
||||||
|
// Use Promise.allSettled to handle both promises properly
|
||||||
|
const results = await Promise.allSettled([job1Promise, job2Promise]);
|
||||||
|
|
||||||
|
// job1 might succeed or fail depending on timing
|
||||||
|
// job2 should be rejected
|
||||||
|
const job2Result = results[1];
|
||||||
|
if (job2Result.status === "rejected") {
|
||||||
|
expect(job2Result.reason.message).toBe("Queue closed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Concurrency edge cases", () => {
|
||||||
|
it("should handle concurrency 1 with 1 app correctly", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await queue.add("app1", { id: "job1" });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect(processed).toEqual(["job1"]);
|
||||||
|
expect(queue.getActiveGroupsCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle concurrency 1 with 3 apps correctly", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string; app: string }>(1);
|
||||||
|
const processingTimes: Map<string, { start: number; end: number }> =
|
||||||
|
new Map();
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
const start = Date.now();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
const end = Date.now();
|
||||||
|
processingTimes.set(data.app, { start, end });
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
queue.add("app1", { id: "job1", app: "app1" }),
|
||||||
|
queue.add("app2", { id: "job2", app: "app2" }),
|
||||||
|
queue.add("app3", { id: "job3", app: "app3" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
|
// Verify sequential processing
|
||||||
|
const app1 = processingTimes.get("app1")!;
|
||||||
|
const app2 = processingTimes.get("app2")!;
|
||||||
|
const app3 = processingTimes.get("app3")!;
|
||||||
|
|
||||||
|
expect(app2.start).toBeGreaterThanOrEqual(app1.end);
|
||||||
|
expect(app3.start).toBeGreaterThanOrEqual(app2.end);
|
||||||
|
expect(queue.getActiveGroupsCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 4 apps with concurrency 3 correctly", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string; app: string }>(3);
|
||||||
|
const concurrentCounts: number[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async () => {
|
||||||
|
// Track concurrent processing
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
concurrentCounts.push(queue.getActiveGroupsCount());
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
queue.add("app1", { id: "job1", app: "app1" }),
|
||||||
|
queue.add("app2", { id: "job2", app: "app2" }),
|
||||||
|
queue.add("app3", { id: "job3", app: "app3" }),
|
||||||
|
queue.add("app4", { id: "job4", app: "app4" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
// Should never exceed concurrency of 3
|
||||||
|
const maxConcurrent = Math.max(...concurrentCounts);
|
||||||
|
expect(maxConcurrent).toBeLessThanOrEqual(3);
|
||||||
|
expect(queue.getActiveGroupsCount()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Idle state", () => {
|
||||||
|
it("should be idle when no jobs are processing", () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
expect(queue.isIdle()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not be idle while processing", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
let isIdleDuringProcessing = false;
|
||||||
|
|
||||||
|
queue.setHandler(async () => {
|
||||||
|
isIdleDuringProcessing = queue.isIdle();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
await queue.add("group1", { id: "job1" });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
|
||||||
|
expect(isIdleDuringProcessing).toBe(false);
|
||||||
|
expect(queue.isIdle()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Concurrency management", () => {
|
||||||
|
it("should get current concurrency", () => {
|
||||||
|
const queue1 = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const queue2 = new GroupedQueue<{ id: string }>(5);
|
||||||
|
const queue3 = new GroupedQueue<{ id: string }>(10);
|
||||||
|
|
||||||
|
expect(queue1.getConcurrency()).toBe(1);
|
||||||
|
expect(queue2.getConcurrency()).toBe(5);
|
||||||
|
expect(queue3.getConcurrency()).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set concurrency dynamically", () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
expect(queue.getConcurrency()).toBe(1);
|
||||||
|
|
||||||
|
queue.setConcurrency(3);
|
||||||
|
expect(queue.getConcurrency()).toBe(3);
|
||||||
|
|
||||||
|
queue.setConcurrency(5);
|
||||||
|
expect(queue.getConcurrency()).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error when setting concurrency less than 1", () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
expect(() => queue.setConcurrency(0)).toThrow(
|
||||||
|
"Concurrency must be at least 1",
|
||||||
|
);
|
||||||
|
expect(() => queue.setConcurrency(-1)).toThrow(
|
||||||
|
"Concurrency must be at least 1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should process next group when concurrency increases", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs to 3 different groups with concurrency 1
|
||||||
|
const job1Promise = queue.add("group1", { id: "job1" });
|
||||||
|
const job2Promise = queue.add("group2", { id: "job2" });
|
||||||
|
const job3Promise = queue.add("group3", { id: "job3" });
|
||||||
|
|
||||||
|
// Wait a bit to ensure job1 starts processing
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Increase concurrency to 3 - should allow group2 and group3 to start
|
||||||
|
queue.setConcurrency(3);
|
||||||
|
|
||||||
|
// Wait for all to complete
|
||||||
|
await Promise.all([job1Promise, job2Promise, job3Promise]);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect(processed).toHaveLength(3);
|
||||||
|
expect(processed).toContain("job1");
|
||||||
|
expect(processed).toContain("job2");
|
||||||
|
expect(processed).toContain("job3");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Clear all pending tasks", () => {
|
||||||
|
it("should clear all pending tasks across all groups", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add multiple jobs to different groups
|
||||||
|
const job1Promise = queue.add("group1", { id: "job1" });
|
||||||
|
const job2Promise = queue.add("group1", { id: "job2" });
|
||||||
|
const job3Promise = queue.add("group2", { id: "job3" });
|
||||||
|
const job4Promise = queue.add("group2", { id: "job4" });
|
||||||
|
const job5Promise = queue.add("group3", { id: "job5" });
|
||||||
|
|
||||||
|
// Wait a bit to ensure job1 starts processing
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Clear all pending tasks
|
||||||
|
const clearedCount = queue.clearAllPendingTasks();
|
||||||
|
|
||||||
|
// Should have cleared 4 pending tasks (job2, job3, job4, job5)
|
||||||
|
// job1 is processing so it's not in the queue anymore
|
||||||
|
expect(clearedCount).toBe(4);
|
||||||
|
|
||||||
|
// Handle all promises
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
job1Promise,
|
||||||
|
job2Promise,
|
||||||
|
job3Promise,
|
||||||
|
job4Promise,
|
||||||
|
job5Promise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// job1 should succeed (it was processing)
|
||||||
|
const job1Result = results[0];
|
||||||
|
expect(job1Result.status).toBe("fulfilled");
|
||||||
|
|
||||||
|
// All pending jobs should be rejected
|
||||||
|
for (let i = 1; i < results.length; i++) {
|
||||||
|
const result = results[i];
|
||||||
|
if (result && result.status === "rejected") {
|
||||||
|
expect(result.reason.message).toBe(
|
||||||
|
"Concurrency changed - queue cleared",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for job1 to complete
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
// Only job1 should have processed
|
||||||
|
expect(processed).toHaveLength(1);
|
||||||
|
expect(processed).toContain("job1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not clear tasks that are currently processing", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs - first one will start processing immediately
|
||||||
|
const job1Promise = queue.add("group1", { id: "job1" });
|
||||||
|
const job2Promise = queue.add("group1", { id: "job2" });
|
||||||
|
|
||||||
|
// Wait to ensure job1 is processing (it's been shifted from tasks)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
|
||||||
|
// Clear all pending - should only clear job2, not job1
|
||||||
|
// job1 is already executing (not in tasks array)
|
||||||
|
const clearedCount = queue.clearAllPendingTasks();
|
||||||
|
|
||||||
|
expect(clearedCount).toBe(1);
|
||||||
|
|
||||||
|
// Handle all promises
|
||||||
|
const results = await Promise.allSettled([job1Promise, job2Promise]);
|
||||||
|
|
||||||
|
// job1 should succeed (it was processing)
|
||||||
|
const job1Result = results[0];
|
||||||
|
expect(job1Result.status).toBe("fulfilled");
|
||||||
|
|
||||||
|
// job2 should be rejected
|
||||||
|
const job2Result = results[1];
|
||||||
|
if (job2Result && job2Result.status === "rejected") {
|
||||||
|
expect(job2Result.reason.message).toBe(
|
||||||
|
"Concurrency changed - queue cleared",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
// Only job1 should have processed
|
||||||
|
expect(processed).toHaveLength(1);
|
||||||
|
expect(processed).toContain("job1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 0 when no pending tasks", () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const clearedCount = queue.clearAllPendingTasks();
|
||||||
|
expect(clearedCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear tasks from multiple groups", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs to multiple groups
|
||||||
|
const promises = [
|
||||||
|
queue.add("group1", { id: "job1" }),
|
||||||
|
queue.add("group1", { id: "job2" }),
|
||||||
|
queue.add("group2", { id: "job3" }),
|
||||||
|
queue.add("group2", { id: "job4" }),
|
||||||
|
queue.add("group3", { id: "job5" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Wait a bit for first job to start (it gets shifted from tasks)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Clear all pending
|
||||||
|
const clearedCount = queue.clearAllPendingTasks();
|
||||||
|
|
||||||
|
// Should clear 4 tasks (job2, job3, job4, job5)
|
||||||
|
// job1 is processing so it's not in the queue anymore
|
||||||
|
expect(clearedCount).toBe(4);
|
||||||
|
|
||||||
|
// Handle all promises
|
||||||
|
const results = await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
// job1 should succeed
|
||||||
|
const job1Result = results[0];
|
||||||
|
expect(job1Result?.status).toBe("fulfilled");
|
||||||
|
|
||||||
|
// Others should be rejected
|
||||||
|
for (let i = 1; i < results.length; i++) {
|
||||||
|
const result = results[i];
|
||||||
|
if (result && result.status === "rejected") {
|
||||||
|
expect(result.reason.message).toBe(
|
||||||
|
"Concurrency changed - queue cleared",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Only first job should process
|
||||||
|
expect(processed.length).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Concurrency change with pending tasks", () => {
|
||||||
|
it("should clear pending tasks when concurrency changes", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs with concurrency 1
|
||||||
|
const job1Promise = queue.add("group1", { id: "job1" });
|
||||||
|
const job2Promise = queue.add("group1", { id: "job2" });
|
||||||
|
const job3Promise = queue.add("group2", { id: "job3" });
|
||||||
|
|
||||||
|
// Wait for job1 to start processing (it gets shifted from tasks)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Change concurrency - should clear pending tasks via clearAllPendingTasks
|
||||||
|
queue.setConcurrency(3);
|
||||||
|
|
||||||
|
// Handle all promises
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
job1Promise,
|
||||||
|
job2Promise,
|
||||||
|
job3Promise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// job1 should succeed (it was processing)
|
||||||
|
const job1Result = results[0];
|
||||||
|
expect(job1Result.status).toBe("fulfilled");
|
||||||
|
|
||||||
|
// Pending jobs should be rejected (job2 and job3 were in queue when cleared)
|
||||||
|
const job2Result = results[1];
|
||||||
|
const job3Result = results[2];
|
||||||
|
|
||||||
|
// At least one of the pending jobs should be rejected
|
||||||
|
const rejectedCount = [job2Result, job3Result].filter(
|
||||||
|
(r) => r && r.status === "rejected",
|
||||||
|
).length;
|
||||||
|
expect(rejectedCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify rejection messages
|
||||||
|
if (job2Result && job2Result.status === "rejected") {
|
||||||
|
expect(job2Result.reason.message).toBe(
|
||||||
|
"Concurrency changed - queue cleared",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (job3Result && job3Result.status === "rejected") {
|
||||||
|
expect(job3Result.reason.message).toBe(
|
||||||
|
"Concurrency changed - queue cleared",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// job1 should have processed, others may or may not depending on timing
|
||||||
|
expect(processed.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(processed).toContain("job1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow new jobs after concurrency change", async () => {
|
||||||
|
const queue = new GroupedQueue<{ id: string }>(1);
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
queue.setHandler(async (data) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add job with concurrency 1
|
||||||
|
const job1Promise = queue.add("group1", { id: "job1" });
|
||||||
|
const job2Promise = queue.add("group1", { id: "job2" });
|
||||||
|
|
||||||
|
// Wait for job1 to start (it gets shifted from tasks)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Change concurrency to 3 - this calls clearAllPendingTasks internally
|
||||||
|
queue.setConcurrency(3);
|
||||||
|
|
||||||
|
// Handle all promises
|
||||||
|
const results = await Promise.allSettled([job1Promise, job2Promise]);
|
||||||
|
|
||||||
|
// job1 should succeed (it was processing)
|
||||||
|
const job1Result = results[0];
|
||||||
|
expect(job1Result.status).toBe("fulfilled");
|
||||||
|
|
||||||
|
// job2 should be rejected (it was in queue when cleared)
|
||||||
|
const job2Result = results[1];
|
||||||
|
if (job2Result && job2Result.status === "rejected") {
|
||||||
|
expect(job2Result.reason.message).toBe(
|
||||||
|
"Concurrency changed - queue cleared",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If job2 wasn't rejected, it means it started processing before clear
|
||||||
|
// This is acceptable as it's a timing issue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new jobs after concurrency change - they should work
|
||||||
|
await Promise.all([
|
||||||
|
queue.add("group2", { id: "job3" }),
|
||||||
|
queue.add("group3", { id: "job4" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// job1, job3, and job4 should have processed
|
||||||
|
expect(processed.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(processed).toContain("job1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
313
apps/dokploy/__test__/queues/queue-manager.test.ts
Normal file
313
apps/dokploy/__test__/queues/queue-manager.test.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { QueueManager } from "../../server/queues/queue-manager";
|
||||||
|
|
||||||
|
describe("QueueManager", () => {
|
||||||
|
let manager: QueueManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = new QueueManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Queue creation and retrieval", () => {
|
||||||
|
it("should create a queue with default concurrency 1", () => {
|
||||||
|
const queue = manager.getQueue("test-queue");
|
||||||
|
expect(queue.getConcurrency()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a queue with custom concurrency", () => {
|
||||||
|
const queue = manager.getQueue("test-queue", 5);
|
||||||
|
expect(queue.getConcurrency()).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return the same queue instance for the same name", () => {
|
||||||
|
const queue1 = manager.getQueue("test-queue", 3);
|
||||||
|
const queue2 = manager.getQueue("test-queue", 5);
|
||||||
|
expect(queue1).toBe(queue2);
|
||||||
|
// Concurrency should remain as first set
|
||||||
|
expect(queue1.getConcurrency()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create different queues for different names", () => {
|
||||||
|
const queue1 = manager.getQueue("queue1", 2);
|
||||||
|
const queue2 = manager.getQueue("queue2", 4);
|
||||||
|
expect(queue1).not.toBe(queue2);
|
||||||
|
expect(queue1.getConcurrency()).toBe(2);
|
||||||
|
expect(queue2.getConcurrency()).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Handler management", () => {
|
||||||
|
it("should set handler for a queue", async () => {
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
manager.setHandler("test-queue", async (data: { id: string }) => {
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.add("test-queue", "group1", { id: "job1" });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(processed).toEqual(["job1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle different handlers for different queues", async () => {
|
||||||
|
const queue1Processed: string[] = [];
|
||||||
|
const queue2Processed: string[] = [];
|
||||||
|
|
||||||
|
manager.setHandler("queue1", async (data: { id: string }) => {
|
||||||
|
queue1Processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.setHandler("queue2", async (data: { id: string }) => {
|
||||||
|
queue2Processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
manager.add("queue1", "group1", { id: "job1" }),
|
||||||
|
manager.add("queue2", "group1", { id: "job2" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(queue1Processed).toEqual(["job1"]);
|
||||||
|
expect(queue2Processed).toEqual(["job2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Job management", () => {
|
||||||
|
it("should add jobs to correct queue and group", async () => {
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
manager.setHandler("test-queue", async (data: { id: string }) => {
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.add("test-queue", "group1", { id: "job1" });
|
||||||
|
await manager.add("test-queue", "group2", { id: "job2" });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(processed).toContain("job1");
|
||||||
|
expect(processed).toContain("job2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create queue with concurrency when adding job", async () => {
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
// Create queue with concurrency first (without handler)
|
||||||
|
manager.getQueue("new-queue", 3);
|
||||||
|
|
||||||
|
// Set handler
|
||||||
|
manager.setHandler("new-queue", async (data: { id: string }) => {
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now add job - it should process
|
||||||
|
await manager.add("new-queue", "group1", { id: "job1" });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
const queue = manager.getQueue("new-queue");
|
||||||
|
expect(queue.getConcurrency()).toBe(3);
|
||||||
|
expect(processed).toEqual(["job1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Queue operations", () => {
|
||||||
|
it("should clear group in specific queue", async () => {
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
manager.setHandler("test-queue", async (data: { id: string }) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs but don't await - they'll start processing
|
||||||
|
const job1Promise = manager.add("test-queue", "group1", { id: "job1" });
|
||||||
|
const job2Promise = manager.add("test-queue", "group1", { id: "job2" });
|
||||||
|
|
||||||
|
// Clear immediately - job1 might be processing, but job2 should be cleared
|
||||||
|
manager.clearGroup("test-queue", "group1");
|
||||||
|
|
||||||
|
// Use Promise.allSettled to handle both promises properly
|
||||||
|
const results = await Promise.allSettled([job1Promise, job2Promise]);
|
||||||
|
|
||||||
|
// job1 might succeed or fail depending on timing
|
||||||
|
// job2 should be rejected
|
||||||
|
const job2Result = results[1];
|
||||||
|
if (job2Result.status === "rejected") {
|
||||||
|
expect(job2Result.reason.message).toBe("Queue cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
// Job1 might have processed, but job2 should not
|
||||||
|
expect(processed.length).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get group length for specific queue", async () => {
|
||||||
|
manager.setHandler("test-queue", async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs without awaiting - check length immediately
|
||||||
|
const job1Promise = manager.add("test-queue", "group1", { id: "job1" });
|
||||||
|
const job2Promise = manager.add("test-queue", "group1", { id: "job2" });
|
||||||
|
|
||||||
|
// Check length immediately - at least one should be pending
|
||||||
|
// (job1 might be processing, but job2 should be pending)
|
||||||
|
const length = manager.getGroupLength("test-queue", "group1");
|
||||||
|
expect(length).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// Wait for both to complete
|
||||||
|
await Promise.all([job1Promise, job2Promise]);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
|
||||||
|
expect(manager.getGroupLength("test-queue", "group1")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get total length for specific queue", async () => {
|
||||||
|
manager.setHandler("test-queue", async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs without awaiting - check length immediately
|
||||||
|
const promises = [
|
||||||
|
manager.add("test-queue", "group1", { id: "job1" }),
|
||||||
|
manager.add("test-queue", "group2", { id: "job2" }),
|
||||||
|
manager.add("test-queue", "group3", { id: "job3" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check length immediately - at least some should be pending
|
||||||
|
const length = manager.getTotalLength("test-queue");
|
||||||
|
expect(length).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
// Wait for all to complete
|
||||||
|
await Promise.all(promises);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect(manager.getTotalLength("test-queue")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should check if queue is idle", async () => {
|
||||||
|
manager.setHandler("test-queue", async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.isIdle("test-queue")).toBe(true);
|
||||||
|
|
||||||
|
await manager.add("test-queue", "group1", { id: "job1" });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect(manager.isIdle("test-queue")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Queue lifecycle", () => {
|
||||||
|
it("should close a specific queue", async () => {
|
||||||
|
manager.setHandler("test-queue", async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add first job and wait a bit to ensure it starts processing
|
||||||
|
const job1Promise = manager.add("test-queue", "group1", { id: "job1" });
|
||||||
|
// Add second job without awaiting
|
||||||
|
const job2Promise = manager.add("test-queue", "group1", { id: "job2" });
|
||||||
|
|
||||||
|
// Wait a tiny bit to ensure job2 is queued
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Close queue - job2 should be rejected
|
||||||
|
await manager.closeQueue("test-queue");
|
||||||
|
|
||||||
|
// Use Promise.allSettled to handle both promises properly
|
||||||
|
const results = await Promise.allSettled([job1Promise, job2Promise]);
|
||||||
|
|
||||||
|
// job1 might succeed or fail depending on timing
|
||||||
|
// job2 should be rejected
|
||||||
|
const job2Result = results[1];
|
||||||
|
if (job2Result.status === "rejected") {
|
||||||
|
expect(job2Result.reason.message).toBe("Queue closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(manager.getQueueNames()).not.toContain("test-queue");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should close all queues", async () => {
|
||||||
|
manager.setHandler("queue1", async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
});
|
||||||
|
manager.setHandler("queue2", async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.add("queue1", "group1", { id: "job1" });
|
||||||
|
await manager.add("queue2", "group1", { id: "job2" });
|
||||||
|
|
||||||
|
await manager.closeAll();
|
||||||
|
|
||||||
|
expect(manager.getQueueNames()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should get all queue names", () => {
|
||||||
|
manager.getQueue("queue1");
|
||||||
|
manager.getQueue("queue2");
|
||||||
|
manager.getQueue("queue3");
|
||||||
|
|
||||||
|
const names = manager.getQueueNames();
|
||||||
|
expect(names).toContain("queue1");
|
||||||
|
expect(names).toContain("queue2");
|
||||||
|
expect(names).toContain("queue3");
|
||||||
|
expect(names).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple queues with different concurrency", () => {
|
||||||
|
it("should handle multiple queues with different concurrency settings", async () => {
|
||||||
|
const queue1Processed: string[] = [];
|
||||||
|
const queue2Processed: string[] = [];
|
||||||
|
|
||||||
|
// Create queues with specific concurrency FIRST, before setting handlers
|
||||||
|
const queue1 = manager.getQueue("queue1", 1);
|
||||||
|
const queue2 = manager.getQueue("queue2", 3);
|
||||||
|
|
||||||
|
// Verify concurrency is set correctly before proceeding
|
||||||
|
expect(queue1.getConcurrency()).toBe(1);
|
||||||
|
expect(queue2.getConcurrency()).toBe(3);
|
||||||
|
|
||||||
|
manager.setHandler("queue1", async (data: { id: string }) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
queue1Processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.setHandler("queue2", async (data: { id: string }) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
queue2Processed.push(data.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue1 with concurrency 1 (sequential)
|
||||||
|
await Promise.all([
|
||||||
|
manager.add("queue1", "app1", { id: "job1" }),
|
||||||
|
manager.add("queue1", "app2", { id: "job2" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Queue2 with concurrency 3 (parallel)
|
||||||
|
await Promise.all([
|
||||||
|
manager.add("queue2", "app1", { id: "job1" }),
|
||||||
|
manager.add("queue2", "app2", { id: "job2" }),
|
||||||
|
manager.add("queue2", "app3", { id: "job3" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
expect(queue1Processed).toHaveLength(2);
|
||||||
|
expect(queue2Processed).toHaveLength(3);
|
||||||
|
|
||||||
|
// Verify concurrency settings are still correct
|
||||||
|
expect(manager.getQueue("queue1").getConcurrency()).toBe(1);
|
||||||
|
expect(manager.getQueue("queue2").getConcurrency()).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
250
apps/dokploy/__test__/queues/queue-setup.test.ts
Normal file
250
apps/dokploy/__test__/queues/queue-setup.test.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import type { DeploymentJob } from "../../server/queues/queue-types";
|
||||||
|
import {
|
||||||
|
getConcurrency,
|
||||||
|
myQueue,
|
||||||
|
setConcurrency,
|
||||||
|
} from "../../server/queues/queueSetup";
|
||||||
|
|
||||||
|
describe("queueSetup", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset concurrency to default (1) before each test
|
||||||
|
setConcurrency(1);
|
||||||
|
// Clear all pending tasks
|
||||||
|
myQueue.clearAllPendingTasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getConcurrency", () => {
|
||||||
|
it("should return default concurrency of 1", () => {
|
||||||
|
const concurrency = getConcurrency();
|
||||||
|
expect(concurrency).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return current concurrency after setting it", () => {
|
||||||
|
setConcurrency(3);
|
||||||
|
expect(getConcurrency()).toBe(3);
|
||||||
|
|
||||||
|
setConcurrency(5);
|
||||||
|
expect(getConcurrency()).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setConcurrency", () => {
|
||||||
|
it("should set concurrency successfully", () => {
|
||||||
|
const clearedCount = setConcurrency(3);
|
||||||
|
expect(getConcurrency()).toBe(3);
|
||||||
|
expect(clearedCount).toBe(0); // No pending tasks to clear
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error for concurrency less than 1", () => {
|
||||||
|
expect(() => setConcurrency(0)).toThrow("Concurrency must be at least 1");
|
||||||
|
expect(() => setConcurrency(-1)).toThrow(
|
||||||
|
"Concurrency must be at least 1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 0 cleared builds when no pending tasks", () => {
|
||||||
|
const clearedCount = setConcurrency(2);
|
||||||
|
expect(clearedCount).toBe(0);
|
||||||
|
expect(getConcurrency()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear pending builds when concurrency changes", async () => {
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
// Set handler
|
||||||
|
myQueue.setHandler(async (job: DeploymentJob) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
if (job.applicationType === "application") {
|
||||||
|
processed.push(job.applicationId);
|
||||||
|
} else if (job.applicationType === "compose") {
|
||||||
|
processed.push(job.composeId);
|
||||||
|
} else if (job.applicationType === "application-preview") {
|
||||||
|
processed.push(job.previewDeploymentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add jobs to different groups
|
||||||
|
const job1: DeploymentJob = {
|
||||||
|
applicationId: "app1",
|
||||||
|
titleLog: "Test",
|
||||||
|
descriptionLog: "Test",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
server: false,
|
||||||
|
};
|
||||||
|
const job2: DeploymentJob = {
|
||||||
|
applicationId: "app2",
|
||||||
|
titleLog: "Test",
|
||||||
|
descriptionLog: "Test",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
server: false,
|
||||||
|
};
|
||||||
|
const job3: DeploymentJob = {
|
||||||
|
applicationId: "app3",
|
||||||
|
titleLog: "Test",
|
||||||
|
descriptionLog: "Test",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
server: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add jobs without awaiting
|
||||||
|
const promise1 = myQueue.add("application:app1", job1);
|
||||||
|
const promise2 = myQueue.add("application:app2", job2);
|
||||||
|
const promise3 = myQueue.add("application:app3", job3);
|
||||||
|
|
||||||
|
// Wait for first job to start processing
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Change concurrency - should clear pending builds
|
||||||
|
const clearedCount = setConcurrency(3);
|
||||||
|
|
||||||
|
// Should have cleared 2 pending builds (app2 and app3)
|
||||||
|
expect(clearedCount).toBe(2);
|
||||||
|
expect(getConcurrency()).toBe(3);
|
||||||
|
|
||||||
|
// Handle all promises - use allSettled to handle both resolved and rejected
|
||||||
|
const results = await Promise.allSettled([promise1, promise2, promise3]);
|
||||||
|
|
||||||
|
// job1 should succeed (it was processing), others should be rejected
|
||||||
|
const job1Result = results[0];
|
||||||
|
if (job1Result.status === "fulfilled") {
|
||||||
|
// Job1 completed successfully
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending jobs should be rejected
|
||||||
|
const job2Result = results[1];
|
||||||
|
const job3Result = results[2];
|
||||||
|
if (job2Result && job2Result.status === "rejected") {
|
||||||
|
expect(job2Result.reason.message).toBe(
|
||||||
|
"Concurrency changed - queue cleared",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (job3Result && job3Result.status === "rejected") {
|
||||||
|
expect(job3Result.reason.message).toBe(
|
||||||
|
"Concurrency changed - queue cleared",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
// Only first job should have processed
|
||||||
|
expect(processed.length).toBeLessThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not clear builds when concurrency doesn't change", async () => {
|
||||||
|
// Set to 2
|
||||||
|
setConcurrency(2);
|
||||||
|
expect(getConcurrency()).toBe(2);
|
||||||
|
|
||||||
|
// Set to 2 again - should not clear anything
|
||||||
|
const clearedCount = setConcurrency(2);
|
||||||
|
expect(clearedCount).toBe(0);
|
||||||
|
expect(getConcurrency()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow new jobs after concurrency change", async () => {
|
||||||
|
const processed: string[] = [];
|
||||||
|
|
||||||
|
myQueue.setHandler(async (job: DeploymentJob) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
if (job.applicationType === "application") {
|
||||||
|
processed.push(job.applicationId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add job with concurrency 1
|
||||||
|
const job1: DeploymentJob = {
|
||||||
|
applicationId: "app1",
|
||||||
|
titleLog: "Test",
|
||||||
|
descriptionLog: "Test",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
server: false,
|
||||||
|
};
|
||||||
|
const job2: DeploymentJob = {
|
||||||
|
applicationId: "app2",
|
||||||
|
titleLog: "Test",
|
||||||
|
descriptionLog: "Test",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
server: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise1 = myQueue.add("application:app1", job1);
|
||||||
|
const promise2 = myQueue.add("application:app2", job2);
|
||||||
|
|
||||||
|
// Wait for first job to start
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
// Change concurrency to 3
|
||||||
|
const clearedCount = setConcurrency(3);
|
||||||
|
expect(clearedCount).toBe(1); // app2 should be cleared
|
||||||
|
|
||||||
|
// Handle all promises - use allSettled to handle both resolved and rejected
|
||||||
|
const results = await Promise.allSettled([promise1, promise2]);
|
||||||
|
|
||||||
|
// job1 should succeed (it was processing)
|
||||||
|
const job1Result = results[0];
|
||||||
|
if (job1Result.status === "fulfilled") {
|
||||||
|
// Job1 completed successfully
|
||||||
|
}
|
||||||
|
|
||||||
|
// app2 should be rejected
|
||||||
|
const job2Result = results[1];
|
||||||
|
if (job2Result.status === "rejected") {
|
||||||
|
expect(job2Result.reason.message).toBe(
|
||||||
|
"Concurrency changed - queue cleared",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new jobs after concurrency change - they should work
|
||||||
|
const job3: DeploymentJob = {
|
||||||
|
applicationId: "app3",
|
||||||
|
titleLog: "Test",
|
||||||
|
descriptionLog: "Test",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
server: false,
|
||||||
|
};
|
||||||
|
const job4: DeploymentJob = {
|
||||||
|
applicationId: "app4",
|
||||||
|
titleLog: "Test",
|
||||||
|
descriptionLog: "Test",
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
server: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
myQueue.add("application:app3", job3),
|
||||||
|
myQueue.add("application:app4", job4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
// app1, app3, and app4 should have processed
|
||||||
|
expect(processed.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(processed).toContain("app1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple concurrency changes correctly", () => {
|
||||||
|
// Start at 1
|
||||||
|
expect(getConcurrency()).toBe(1);
|
||||||
|
|
||||||
|
// Change to 3
|
||||||
|
setConcurrency(3);
|
||||||
|
expect(getConcurrency()).toBe(3);
|
||||||
|
|
||||||
|
// Change to 5
|
||||||
|
setConcurrency(5);
|
||||||
|
expect(getConcurrency()).toBe(5);
|
||||||
|
|
||||||
|
// Change back to 1
|
||||||
|
setConcurrency(1);
|
||||||
|
expect(getConcurrency()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,13 +1,10 @@
|
|||||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
|
||||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||||
|
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||||
|
|
||||||
type MockCreateServiceOptions = {
|
type MockCreateServiceOptions = {
|
||||||
TaskTemplate?: {
|
StopGracePeriod?: number;
|
||||||
ContainerSpec?: {
|
|
||||||
StopGracePeriod?: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,10 +82,8 @@ describe("mechanizeDockerContainer", () => {
|
|||||||
throw new Error("createServiceMock should have been called once");
|
throw new Error("createServiceMock should have been called once");
|
||||||
}
|
}
|
||||||
const [settings] = call;
|
const [settings] = call;
|
||||||
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
|
expect(settings.StopGracePeriod).toBe(0);
|
||||||
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
|
expect(typeof settings.StopGracePeriod).toBe("number");
|
||||||
"number",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
||||||
@@ -102,8 +97,6 @@ describe("mechanizeDockerContainer", () => {
|
|||||||
throw new Error("createServiceMock should have been called once");
|
throw new Error("createServiceMock should have been called once");
|
||||||
}
|
}
|
||||||
const [settings] = call;
|
const [settings] = call;
|
||||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
|
expect(settings).not.toHaveProperty("StopGracePeriod");
|
||||||
"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);
|
||||||
|
|||||||
@@ -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,24 +3,16 @@ 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,
|
endpointSpecSwarm: null,
|
||||||
@@ -50,7 +42,6 @@ const baseApp: ApplicationNested = {
|
|||||||
environmentId: "",
|
environmentId: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
isDefault: false,
|
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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: () =>
|
||||||
@@ -184,20 +163,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 />
|
||||||
@@ -223,20 +198,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 />
|
||||||
@@ -263,20 +234,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 />
|
||||||
@@ -303,21 +271,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,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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -25,7 +25,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";
|
||||||
|
|
||||||
@@ -143,10 +142,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} />
|
||||||
)}
|
)}
|
||||||
@@ -256,9 +252,9 @@ export const ShowDeployments = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={deployment.deploymentId}
|
key={deployment.deploymentId}
|
||||||
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 flex-col min-w-0">
|
<div className="flex flex-col">
|
||||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||||
{index + 1}. {deployment.status}
|
{index + 1}. {deployment.status}
|
||||||
<StatusTooltip
|
<StatusTooltip
|
||||||
@@ -313,8 +309,8 @@ export const ShowDeployments = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
|
||||||
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
|
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||||
<DateTooltip date={deployment.createdAt} />
|
<DateTooltip date={deployment.createdAt} />
|
||||||
{deployment.startedAt && deployment.finishedAt && (
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -333,7 +329,7 @@ export const ShowDeployments = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<DialogAction
|
||||||
title="Kill Process"
|
title="Kill Process"
|
||||||
@@ -355,7 +351,6 @@ export const ShowDeployments = ({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isKillingProcess}
|
isLoading={isKillingProcess}
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
>
|
||||||
Kill Process
|
Kill Process
|
||||||
</Button>
|
</Button>
|
||||||
@@ -365,7 +360,6 @@ export const ShowDeployments = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveLog(deployment);
|
setActiveLog(deployment);
|
||||||
}}
|
}}
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
@@ -375,19 +369,7 @@ export const ShowDeployments = ({
|
|||||||
type === "application" && (
|
type === "application" && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Rollback to this deployment"
|
title="Rollback to this deployment"
|
||||||
description={
|
description="Are you sure you want to rollback to this deployment?"
|
||||||
<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"
|
type="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await rollback({
|
await rollback({
|
||||||
@@ -407,7 +389,6 @@ export const ShowDeployments = ({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isRollingBack}
|
isLoading={isRollingBack}
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
>
|
||||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||||
Rollback
|
Rollback
|
||||||
@@ -422,7 +403,7 @@ export const ShowDeployments = ({
|
|||||||
</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>
|
||||||
|
|||||||
@@ -5,23 +5,14 @@ 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(),
|
buildSecrets: z.string(),
|
||||||
createEnvFile: z.boolean(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||||
@@ -48,7 +39,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: "",
|
env: "",
|
||||||
buildArgs: "",
|
buildArgs: "",
|
||||||
buildSecrets: "",
|
buildSecrets: "",
|
||||||
createEnvFile: true,
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addEnvironmentSchema),
|
resolver: zodResolver(addEnvironmentSchema),
|
||||||
});
|
});
|
||||||
@@ -57,12 +47,10 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
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 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 || "") ||
|
currentBuildSecrets !== (data?.buildSecrets || "");
|
||||||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -70,7 +58,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: data.env || "",
|
env: data.env || "",
|
||||||
buildArgs: data.buildArgs || "",
|
buildArgs: data.buildArgs || "",
|
||||||
buildSecrets: data.buildSecrets || "",
|
buildSecrets: data.buildSecrets || "",
|
||||||
createEnvFile: data.createEnvFile ?? true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -80,7 +67,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: formData.env,
|
env: formData.env,
|
||||||
buildArgs: formData.buildArgs,
|
buildArgs: formData.buildArgs,
|
||||||
buildSecrets: formData.buildSecrets,
|
buildSecrets: formData.buildSecrets,
|
||||||
createEnvFile: formData.createEnvFile,
|
|
||||||
applicationId,
|
applicationId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -97,7 +83,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: data?.env || "",
|
env: data?.env || "",
|
||||||
buildArgs: data?.buildArgs || "",
|
buildArgs: data?.buildArgs || "",
|
||||||
buildSecrets: data?.buildSecrets || "",
|
buildSecrets: data?.buildSecrets || "",
|
||||||
createEnvFile: data?.createEnvFile ?? true,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,31 +167,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
placeholder="NPM_TOKEN=xyz"
|
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}>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
isLoading={data?.applicationStatus === "running"}
|
// isLoading={data?.applicationStatus === "running"}
|
||||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -101,8 +100,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);
|
||||||
@@ -123,7 +120,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]);
|
||||||
@@ -171,13 +168,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)}
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -15,14 +13,6 @@ 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: "* * * * *" },
|
||||||
@@ -92,7 +75,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) {
|
||||||
@@ -231,7 +213,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
serviceName: "",
|
serviceName: "",
|
||||||
scheduleType: scheduleType || "application",
|
scheduleType: scheduleType || "application",
|
||||||
script: "",
|
script: "",
|
||||||
timezone: undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -270,7 +251,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]);
|
||||||
@@ -484,89 +464,6 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
formControl={form.control}
|
formControl={form.control}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="timezone"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="flex items-center gap-2">
|
|
||||||
Timezone
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
Select a timezone for the schedule. If not
|
|
||||||
specified, UTC will be used.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"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>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[400px] p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search timezone..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No timezone found.</CommandEmpty>
|
|
||||||
<ScrollArea className="h-72">
|
|
||||||
{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>
|
|
||||||
Optional: Choose a timezone for the schedule execution time
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(scheduleTypeForm === "application" ||
|
{(scheduleTypeForm === "application" ||
|
||||||
scheduleTypeForm === "compose") && (
|
scheduleTypeForm === "compose") && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -86,7 +86,7 @@ export const ShowVolumeBackups = ({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Schedule volume backups to run automatically at specified
|
Schedule volume backups to run automatically at specified
|
||||||
intervals
|
intervals.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -1,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,
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -285,14 +283,10 @@ export const ImpersonationBar = () => {
|
|||||||
<AvatarImage
|
<AvatarImage
|
||||||
className="object-cover"
|
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 +299,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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -102,9 +109,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 +130,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 +147,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 +246,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,21 +273,22 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
>
|
>
|
||||||
<PencilIcon className="h-3 w-3" />
|
<PencilIcon className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
{canDeleteEnvironments && !environment.isDefault && (
|
{canDeleteEnvironments && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openDeleteDialog(environment);
|
openDeleteDialog(environment);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-3 w-3" />
|
<TrashIcon className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||||
@@ -55,23 +54,16 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { TimeBadge } from "@/components/ui/time-badge";
|
import { TimeBadge } from "@/components/ui/time-badge";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useDebounce } from "@/utils/hooks/use-debounce";
|
|
||||||
import { HandleProject } from "./handle-project";
|
import { HandleProject } from "./handle-project";
|
||||||
import { ProjectEnvironment } from "./project-environment";
|
import { ProjectEnvironment } from "./project-environment";
|
||||||
|
|
||||||
export const ShowProjects = () => {
|
export const ShowProjects = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const router = useRouter();
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data, isLoading } = api.project.all.useQuery();
|
const { data, isLoading } = api.project.all.useQuery();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { mutateAsync } = api.project.remove.useMutation();
|
const { mutateAsync } = api.project.remove.useMutation();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [searchQuery, setSearchQuery] = useState(
|
|
||||||
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
|
|
||||||
);
|
|
||||||
const debouncedSearchQuery = useDebounce(searchQuery, 500);
|
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<string>(() => {
|
const [sortBy, setSortBy] = useState<string>(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
return localStorage.getItem("projectsSort") || "createdAt-desc";
|
return localStorage.getItem("projectsSort") || "createdAt-desc";
|
||||||
@@ -83,41 +75,14 @@ export const ShowProjects = () => {
|
|||||||
localStorage.setItem("projectsSort", sortBy);
|
localStorage.setItem("projectsSort", sortBy);
|
||||||
}, [sortBy]);
|
}, [sortBy]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!router.isReady) return;
|
|
||||||
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
|
||||||
if (urlQuery !== searchQuery) {
|
|
||||||
setSearchQuery(urlQuery);
|
|
||||||
}
|
|
||||||
}, [router.isReady, router.query.q]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!router.isReady) return;
|
|
||||||
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
|
||||||
if (debouncedSearchQuery === urlQuery) return;
|
|
||||||
|
|
||||||
const newQuery = { ...router.query };
|
|
||||||
if (debouncedSearchQuery) {
|
|
||||||
newQuery.q = debouncedSearchQuery;
|
|
||||||
} else {
|
|
||||||
delete newQuery.q;
|
|
||||||
}
|
|
||||||
router.replace({ pathname: router.pathname, query: newQuery }, undefined, {
|
|
||||||
shallow: true,
|
|
||||||
});
|
|
||||||
}, [debouncedSearchQuery]);
|
|
||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
|
// First filter by search query
|
||||||
const filtered = data.filter(
|
const filtered = data.filter(
|
||||||
(project) =>
|
(project) =>
|
||||||
project.name
|
project.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
.toLowerCase()
|
project.description?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
.includes(debouncedSearchQuery.toLowerCase()) ||
|
|
||||||
project.description
|
|
||||||
?.toLowerCase()
|
|
||||||
.includes(debouncedSearchQuery.toLowerCase()),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Then sort the filtered results
|
// Then sort the filtered results
|
||||||
@@ -165,7 +130,7 @@ export const ShowProjects = () => {
|
|||||||
}
|
}
|
||||||
return direction === "asc" ? comparison : -comparison;
|
return direction === "asc" ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
}, [data, debouncedSearchQuery, sortBy]);
|
}, [data, searchQuery, sortBy]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -173,7 +138,7 @@ export const ShowProjects = () => {
|
|||||||
list={[{ name: "Projects", href: "/dashboard/projects" }]}
|
list={[{ name: "Projects", href: "/dashboard/projects" }]}
|
||||||
/>
|
/>
|
||||||
{!isCloud && (
|
{!isCloud && (
|
||||||
<div className="absolute top-4 right-4">
|
<div className="absolute top-5 right-5">
|
||||||
<TimeBadge />
|
<TimeBadge />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -190,9 +155,7 @@ export const ShowProjects = () => {
|
|||||||
Create and manage your projects
|
Create and manage your projects
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{(auth?.role === "owner" ||
|
{(auth?.role === "owner" || auth?.canCreateProjects) && (
|
||||||
auth?.role === "admin" ||
|
|
||||||
auth?.canCreateProjects) && (
|
|
||||||
<div className="">
|
<div className="">
|
||||||
<HandleProject />
|
<HandleProject />
|
||||||
</div>
|
</div>
|
||||||
@@ -288,17 +251,13 @@ export const ShowProjects = () => {
|
|||||||
)
|
)
|
||||||
.some(Boolean);
|
.some(Boolean);
|
||||||
|
|
||||||
const productionEnvironment = project?.environments.find(
|
|
||||||
(env) => env.isDefault,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={project.projectId}
|
key={project.projectId}
|
||||||
className="w-full lg:max-w-md"
|
className="w-full lg:max-w-md"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/project/${project.projectId}/environment/${productionEnvironment?.environmentId}`}
|
href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`}
|
||||||
>
|
>
|
||||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
||||||
{haveServicesWithDomains ? (
|
{haveServicesWithDomains ? (
|
||||||
|
|||||||
@@ -49,65 +49,51 @@ export const RequestDistributionChart = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-[200px] overflow-hidden">
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
<ResponsiveContainer
|
<ChartContainer config={chartConfig}>
|
||||||
width="100%"
|
<AreaChart
|
||||||
height="100%"
|
accessibilityLayer
|
||||||
className="overflow-hidden"
|
data={stats || []}
|
||||||
>
|
margin={{
|
||||||
<ChartContainer config={chartConfig}>
|
left: 12,
|
||||||
<AreaChart
|
right: 12,
|
||||||
accessibilityLayer
|
}}
|
||||||
data={stats || []}
|
>
|
||||||
margin={{
|
<CartesianGrid vertical={false} />
|
||||||
top: 10,
|
<XAxis
|
||||||
left: 12,
|
dataKey="hour"
|
||||||
right: 12,
|
tickLine={false}
|
||||||
bottom: 0,
|
axisLine={false}
|
||||||
}}
|
tickMargin={8}
|
||||||
>
|
tickFormatter={(value) =>
|
||||||
<CartesianGrid vertical={false} />
|
new Date(value).toLocaleTimeString([], {
|
||||||
<XAxis
|
hour: "2-digit",
|
||||||
dataKey="hour"
|
minute: "2-digit",
|
||||||
tickLine={false}
|
})
|
||||||
axisLine={false}
|
}
|
||||||
tickMargin={8}
|
/>
|
||||||
tickFormatter={(value) =>
|
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
|
||||||
new Date(value).toLocaleTimeString([], {
|
<ChartTooltip
|
||||||
hour: "2-digit",
|
cursor={false}
|
||||||
minute: "2-digit",
|
content={<ChartTooltipContent indicator="line" />}
|
||||||
})
|
labelFormatter={(value) =>
|
||||||
}
|
new Date(value).toLocaleString([], {
|
||||||
/>
|
month: "short",
|
||||||
<YAxis
|
day: "numeric",
|
||||||
tickLine={false}
|
hour: "2-digit",
|
||||||
axisLine={false}
|
minute: "2-digit",
|
||||||
tickMargin={8}
|
})
|
||||||
allowDataOverflow={false}
|
}
|
||||||
domain={[0, "auto"]}
|
/>
|
||||||
/>
|
<Area
|
||||||
<ChartTooltip
|
dataKey="count"
|
||||||
cursor={false}
|
type="natural"
|
||||||
content={<ChartTooltipContent indicator="line" />}
|
fill="hsl(var(--chart-1))"
|
||||||
labelFormatter={(value) =>
|
fillOpacity={0.4}
|
||||||
new Date(value).toLocaleString([], {
|
stroke="hsl(var(--chart-1))"
|
||||||
month: "short",
|
/>
|
||||||
day: "numeric",
|
</AreaChart>
|
||||||
hour: "2-digit",
|
</ChartContainer>
|
||||||
minute: "2-digit",
|
</ResponsiveContainer>
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Area
|
|
||||||
dataKey="count"
|
|
||||||
type="monotone"
|
|
||||||
fill="hsl(var(--chart-1))"
|
|
||||||
fillOpacity={0.4}
|
|
||||||
stroke="hsl(var(--chart-1))"
|
|
||||||
/>
|
|
||||||
</AreaChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,38 +51,13 @@ export const ShowRequests = () => {
|
|||||||
const { mutateAsync: updateLogCleanup } =
|
const { mutateAsync: updateLogCleanup } =
|
||||||
api.settings.updateLogCleanup.useMutation();
|
api.settings.updateLogCleanup.useMutation();
|
||||||
const [cronExpression, setCronExpression] = useState<string | null>(null);
|
const [cronExpression, setCronExpression] = useState<string | null>(null);
|
||||||
|
|
||||||
// Set default date range to last 3 days
|
|
||||||
const getDefaultDateRange = () => {
|
|
||||||
const to = new Date();
|
|
||||||
const from = new Date();
|
|
||||||
from.setDate(from.getDate() - 3);
|
|
||||||
return { from, to };
|
|
||||||
};
|
|
||||||
|
|
||||||
const [dateRange, setDateRange] = useState<{
|
const [dateRange, setDateRange] = useState<{
|
||||||
from: Date | undefined;
|
from: Date | undefined;
|
||||||
to: Date | undefined;
|
to: Date | undefined;
|
||||||
}>(getDefaultDateRange());
|
}>({
|
||||||
|
from: undefined,
|
||||||
// Check if logs exist to determine if traefik has been reloaded
|
to: undefined,
|
||||||
// Only fetch when active to minimize network calls
|
});
|
||||||
const { data: statsLogsCheck } = api.settings.readStatsLogs.useQuery(
|
|
||||||
{
|
|
||||||
page: {
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!isActive,
|
|
||||||
refetchInterval: 5000, // Check every 5 seconds when active
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine if warning should be shown
|
|
||||||
// Show warning only if active but no logs exist yet
|
|
||||||
const shouldShowWarning = isActive && (statsLogsCheck?.totalCount ?? 0) === 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (logCleanupStatus) {
|
if (logCleanupStatus) {
|
||||||
@@ -104,18 +79,16 @@ export const ShowRequests = () => {
|
|||||||
See all the incoming requests that pass trough Traefik
|
See all the incoming requests that pass trough Traefik
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
||||||
{shouldShowWarning && (
|
<AlertBlock type="warning">
|
||||||
<AlertBlock type="warning">
|
When you activate, you need to reload traefik to apply the
|
||||||
When you activate, you need to reload traefik to apply the
|
changes, you can reload traefik in{" "}
|
||||||
changes, you can reload traefik in{" "}
|
<Link
|
||||||
<Link
|
href="/dashboard/settings/server"
|
||||||
href="/dashboard/settings/server"
|
className="text-primary"
|
||||||
className="text-primary"
|
>
|
||||||
>
|
Settings
|
||||||
Settings
|
</Link>
|
||||||
</Link>
|
</AlertBlock>
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
<div className="flex w-full gap-4 justify-end items-center">
|
<div className="flex w-full gap-4 justify-end items-center">
|
||||||
@@ -196,13 +169,17 @@ export const ShowRequests = () => {
|
|||||||
{isActive ? (
|
{isActive ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-end mb-4 gap-2">
|
<div className="flex justify-end mb-4 gap-2">
|
||||||
<Button
|
{(dateRange.from || dateRange.to) && (
|
||||||
variant="outline"
|
<Button
|
||||||
onClick={() => setDateRange(getDefaultDateRange())}
|
variant="outline"
|
||||||
className="px-3"
|
onClick={() =>
|
||||||
>
|
setDateRange({ from: undefined, to: undefined })
|
||||||
Reset to Last 3 Days
|
}
|
||||||
</Button>
|
className="px-3"
|
||||||
|
>
|
||||||
|
Clear dates
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -89,26 +89,24 @@ export const SearchCommand = () => {
|
|||||||
<CommandGroup heading={"Projects"}>
|
<CommandGroup heading={"Projects"}>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{data?.map((project) => {
|
{data?.map((project) => {
|
||||||
// Find default environment, or fall back to first environment
|
const productionEnvironment = project.environments.find(
|
||||||
const defaultEnvironment =
|
(environment) => environment.name === "production",
|
||||||
project.environments.find(
|
);
|
||||||
(environment) => environment.isDefault,
|
|
||||||
) || project?.environments?.[0];
|
|
||||||
|
|
||||||
if (!defaultEnvironment) return null;
|
if (!productionEnvironment) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={project.projectId}
|
key={project.projectId}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/project/${project.projectId}/environment/${defaultEnvironment.environmentId}`,
|
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
|
||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
||||||
{project.name} / {defaultEnvironment.name}
|
{project.name} / {productionEnvironment!.name}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { CreditCard, FileText } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { ShowInvoices } from "./show-invoices";
|
|
||||||
|
|
||||||
const navigationItems = [
|
|
||||||
{
|
|
||||||
name: "Subscription",
|
|
||||||
href: "/dashboard/settings/billing",
|
|
||||||
icon: CreditCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invoices",
|
|
||||||
href: "/dashboard/settings/invoices",
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ShowBillingInvoices = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
|
||||||
<div className="rounded-xl bg-background shadow-md">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
|
||||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
|
||||||
Billing
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Manage your subscription and invoices
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 py-4 border-t">
|
|
||||||
<nav className="flex space-x-2 border-b">
|
|
||||||
{navigationItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isActive = router.pathname === item.href;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
|
||||||
isActive
|
|
||||||
? "border-primary text-primary"
|
|
||||||
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="mt-6">
|
|
||||||
<ShowInvoices />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -4,13 +4,11 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
FileText,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -39,22 +37,7 @@ export const calculatePrice = (count: number, isAnnual = false) => {
|
|||||||
if (count <= 1) return 4.5;
|
if (count <= 1) return 4.5;
|
||||||
return count * 3.5;
|
return count * 3.5;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navigationItems = [
|
|
||||||
{
|
|
||||||
name: "Subscription",
|
|
||||||
href: "/dashboard/settings/billing",
|
|
||||||
icon: CreditCard,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Invoices",
|
|
||||||
href: "/dashboard/settings/invoices",
|
|
||||||
icon: FileText,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ShowBilling = () => {
|
export const ShowBilling = () => {
|
||||||
const router = useRouter();
|
|
||||||
const { data: servers } = api.server.count.useQuery();
|
const { data: servers } = api.server.count.useQuery();
|
||||||
const { data: admin } = api.user.get.useQuery();
|
const { data: admin } = api.user.get.useQuery();
|
||||||
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
||||||
@@ -93,41 +76,17 @@ export const ShowBilling = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader>
|
<CardHeader className="">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||||
Billing
|
Billing
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Manage your subscription</CardDescription>
|
||||||
Manage your subscription and invoices
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4 py-4 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
<nav className="flex space-x-2 border-b">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
{navigationItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const isActive = router.pathname === item.href;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.name}
|
|
||||||
href={item.href}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
|
||||||
isActive
|
|
||||||
? "border-primary text-primary"
|
|
||||||
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="h-4 w-4" />
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 w-full mt-6">
|
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="monthly"
|
defaultValue="monthly"
|
||||||
value={isAnnual ? "annual" : "monthly"}
|
value={isAnnual ? "annual" : "monthly"}
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
|
|
||||||
import type Stripe from "stripe";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const formatDate = (timestamp: number | null) => {
|
|
||||||
if (!timestamp) return "-";
|
|
||||||
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatAmount = (amount: number, currency: string) => {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: currency.toUpperCase(),
|
|
||||||
}).format(amount / 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
|
|
||||||
const statusConfig: Record<
|
|
||||||
Stripe.Invoice.Status,
|
|
||||||
{ label: string; variant: "default" | "secondary" | "destructive" }
|
|
||||||
> = {
|
|
||||||
paid: { label: "Paid", variant: "default" },
|
|
||||||
open: { label: "Open", variant: "secondary" },
|
|
||||||
draft: { label: "Draft", variant: "secondary" },
|
|
||||||
void: { label: "Void", variant: "destructive" },
|
|
||||||
uncollectible: { label: "Uncollectible", variant: "destructive" },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
return <Badge variant="secondary">Unknown</Badge>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = statusConfig[status] || {
|
|
||||||
label: status,
|
|
||||||
variant: "secondary" as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ShowInvoices = () => {
|
|
||||||
const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center min-h-[20vh]">
|
|
||||||
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center">
|
|
||||||
Loading invoices...
|
|
||||||
<Loader2 className="animate-spin" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : invoices && invoices.length > 0 ? (
|
|
||||||
<div className="rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Invoice</TableHead>
|
|
||||||
<TableHead>Date</TableHead>
|
|
||||||
<TableHead>Due Date</TableHead>
|
|
||||||
<TableHead>Amount</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{invoices.map((invoice) => (
|
|
||||||
<TableRow key={invoice.id}>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
{invoice.number || invoice.id.slice(0, 12)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{formatDate(invoice.created)}</TableCell>
|
|
||||||
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{formatAmount(invoice.amountDue, invoice.currency)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{invoice.hostedInvoiceUrl && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
window.open(
|
|
||||||
invoice.hostedInvoiceUrl || "",
|
|
||||||
"_blank",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{invoice.invoicePdf && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
window.open(invoice.invoicePdf || "", "_blank")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-[20vh] gap-2">
|
|
||||||
<FileText className="size-12 text-muted-foreground" />
|
|
||||||
<p className="text-base text-muted-foreground">No invoices found</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Your invoices will appear here once you have a subscription
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -42,38 +42,12 @@ const AddRegistrySchema = z.object({
|
|||||||
username: z.string().min(1, {
|
username: z.string().min(1, {
|
||||||
message: "Username is required",
|
message: "Username is required",
|
||||||
}),
|
}),
|
||||||
password: z.string(),
|
password: z.string().min(1, {
|
||||||
registryUrl: z
|
message: "Password is required",
|
||||||
.string()
|
}),
|
||||||
.optional()
|
registryUrl: z.string(),
|
||||||
.refine(
|
|
||||||
(val) => {
|
|
||||||
// If empty or undefined, skip validation (field is optional)
|
|
||||||
if (!val || val.trim().length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Validate that it's a valid hostname (no protocol, no path, optional port)
|
|
||||||
// Valid formats: example.com, registry.example.com, [::1], example.com:5000
|
|
||||||
// Invalid: https://example.com, example.com/path
|
|
||||||
const trimmed = val.trim();
|
|
||||||
// Check for protocol or path - these are not allowed
|
|
||||||
if (/^https?:\/\//i.test(trimmed) || trimmed.includes("/")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Basic hostname validation: allow alphanumeric, dots, hyphens, underscores, and IPv6 in brackets
|
|
||||||
// Allow optional port at the end
|
|
||||||
const hostnameRegex =
|
|
||||||
/^(?:\[[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?)(?::\d+)?$/;
|
|
||||||
return hostnameRegex.test(trimmed);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message:
|
|
||||||
"Invalid registry URL. Please enter only the hostname (e.g., example.com or registry.example.com). Do not include protocol (https://) or paths.",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
imagePrefix: z.string(),
|
imagePrefix: z.string(),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
isEditing: z.boolean().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddRegistry = z.infer<typeof AddRegistrySchema>;
|
type AddRegistry = z.infer<typeof AddRegistrySchema>;
|
||||||
@@ -100,21 +74,13 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
const { mutateAsync, error, isError } = registryId
|
const { mutateAsync, error, isError } = registryId
|
||||||
? api.registry.update.useMutation()
|
? api.registry.update.useMutation()
|
||||||
: api.registry.create.useMutation();
|
: api.registry.create.useMutation();
|
||||||
const { data: deployServers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
const { data: buildServers } = api.server.buildServers.useQuery();
|
|
||||||
const servers = [...(deployServers || []), ...(buildServers || [])];
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: testRegistry,
|
mutateAsync: testRegistry,
|
||||||
isLoading,
|
isLoading,
|
||||||
error: testRegistryError,
|
error: testRegistryError,
|
||||||
isError: testRegistryIsError,
|
isError: testRegistryIsError,
|
||||||
} = api.registry.testRegistry.useMutation();
|
} = api.registry.testRegistry.useMutation();
|
||||||
const {
|
|
||||||
mutateAsync: testRegistryById,
|
|
||||||
isLoading: isLoadingById,
|
|
||||||
error: testRegistryByIdError,
|
|
||||||
isError: testRegistryByIdIsError,
|
|
||||||
} = api.registry.testRegistryById.useMutation();
|
|
||||||
const form = useForm<AddRegistry>({
|
const form = useForm<AddRegistry>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
username: "",
|
||||||
@@ -123,26 +89,8 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
imagePrefix: "",
|
imagePrefix: "",
|
||||||
registryName: "",
|
registryName: "",
|
||||||
serverId: "",
|
serverId: "",
|
||||||
isEditing: !!registryId,
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(AddRegistrySchema),
|
||||||
AddRegistrySchema.refine(
|
|
||||||
(data) => {
|
|
||||||
// When creating a new registry, password is required
|
|
||||||
if (
|
|
||||||
!data.isEditing &&
|
|
||||||
(!data.password || data.password.length === 0)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
message: "Password is required",
|
|
||||||
path: ["password"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const password = form.watch("password");
|
const password = form.watch("password");
|
||||||
@@ -151,9 +99,6 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
const registryName = form.watch("registryName");
|
const registryName = form.watch("registryName");
|
||||||
const imagePrefix = form.watch("imagePrefix");
|
const imagePrefix = form.watch("imagePrefix");
|
||||||
const serverId = form.watch("serverId");
|
const serverId = form.watch("serverId");
|
||||||
const selectedServer = servers?.find(
|
|
||||||
(server) => server.serverId === serverId,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (registry) {
|
if (registry) {
|
||||||
@@ -163,7 +108,6 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
registryUrl: registry.registryUrl,
|
registryUrl: registry.registryUrl,
|
||||||
imagePrefix: registry.imagePrefix || "",
|
imagePrefix: registry.imagePrefix || "",
|
||||||
registryName: registry.registryName,
|
registryName: registry.registryName,
|
||||||
isEditing: true,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -172,29 +116,21 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
registryUrl: "",
|
registryUrl: "",
|
||||||
imagePrefix: "",
|
imagePrefix: "",
|
||||||
serverId: "",
|
serverId: "",
|
||||||
isEditing: false,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddRegistry) => {
|
const onSubmit = async (data: AddRegistry) => {
|
||||||
const payload: any = {
|
await mutateAsync({
|
||||||
|
password: data.password,
|
||||||
registryName: data.registryName,
|
registryName: data.registryName,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
registryUrl: data.registryUrl || "",
|
registryUrl: data.registryUrl,
|
||||||
registryType: "cloud",
|
registryType: "cloud",
|
||||||
imagePrefix: data.imagePrefix,
|
imagePrefix: data.imagePrefix,
|
||||||
serverId: data.serverId,
|
serverId: data.serverId,
|
||||||
registryId: registryId || "",
|
registryId: registryId || "",
|
||||||
};
|
})
|
||||||
|
|
||||||
// Only include password if it's been provided (not empty)
|
|
||||||
// When editing, empty password means "keep the existing password"
|
|
||||||
if (data.password && data.password.length > 0) {
|
|
||||||
payload.password = data.password;
|
|
||||||
}
|
|
||||||
|
|
||||||
await mutateAsync(payload)
|
|
||||||
.then(async (_data) => {
|
.then(async (_data) => {
|
||||||
await utils.registry.all.invalidate();
|
await utils.registry.all.invalidate();
|
||||||
toast.success(registryId ? "Registry updated" : "Registry added");
|
toast.success(registryId ? "Registry updated" : "Registry added");
|
||||||
@@ -232,14 +168,11 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
Fill the next fields to add a external registry.
|
Fill the next fields to add a external registry.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{(isError || testRegistryIsError || testRegistryByIdIsError) && (
|
{(isError || testRegistryIsError) && (
|
||||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
{testRegistryError?.message ||
|
{testRegistryError?.message || error?.message || ""}
|
||||||
testRegistryByIdError?.message ||
|
|
||||||
error?.message ||
|
|
||||||
""}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -290,20 +223,10 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password{registryId && " (Optional)"}</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
{registryId && (
|
|
||||||
<FormDescription>
|
|
||||||
Leave blank to keep existing password. Enter new
|
|
||||||
password to test or update it.
|
|
||||||
</FormDescription>
|
|
||||||
)}
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={
|
placeholder="Password"
|
||||||
registryId
|
|
||||||
? "Leave blank to keep existing"
|
|
||||||
: "Password"
|
|
||||||
}
|
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
{...field}
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
@@ -338,10 +261,6 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Registry URL</FormLabel>
|
<FormLabel>Registry URL</FormLabel>
|
||||||
<FormDescription>
|
|
||||||
Enter only the hostname (e.g.,
|
|
||||||
aws_account_id.dkr.ecr.us-west-2.amazonaws.com).
|
|
||||||
</FormDescription>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
|
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
|
||||||
@@ -363,40 +282,8 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
|
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{!isCloud ? (
|
Select a server to test the registry. this will run the
|
||||||
<>
|
following command on the server
|
||||||
{serverId && serverId !== "none" && selectedServer ? (
|
|
||||||
<>
|
|
||||||
Authentication will be performed on{" "}
|
|
||||||
<strong>{selectedServer.name}</strong>. This
|
|
||||||
registry will be available on this server.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Choose where to authenticate with the registry. By
|
|
||||||
default, authentication occurs on the Dokploy
|
|
||||||
server. Select a specific server to authenticate
|
|
||||||
from that server instead.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{serverId && serverId !== "none" && selectedServer ? (
|
|
||||||
<>
|
|
||||||
Authentication will be performed on{" "}
|
|
||||||
<strong>{selectedServer.name}</strong>. This
|
|
||||||
registry will be available on this server.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Select a server to authenticate with the registry.
|
|
||||||
The authentication will be performed from the
|
|
||||||
selected server.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
@@ -407,33 +294,16 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
<SelectValue placeholder="Select a server" />
|
<SelectValue placeholder="Select a server" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{deployServers && deployServers.length > 0 && (
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Deploy Servers</SelectLabel>
|
|
||||||
{deployServers.map((server) => (
|
|
||||||
<SelectItem
|
|
||||||
key={server.serverId}
|
|
||||||
value={server.serverId}
|
|
||||||
>
|
|
||||||
{server.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
)}
|
|
||||||
{buildServers && buildServers.length > 0 && (
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>Build Servers</SelectLabel>
|
|
||||||
{buildServers.map((server) => (
|
|
||||||
<SelectItem
|
|
||||||
key={server.serverId}
|
|
||||||
value={server.serverId}
|
|
||||||
>
|
|
||||||
{server.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
)}
|
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
|
<SelectLabel>Servers</SelectLabel>
|
||||||
|
{servers?.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
{server.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
<SelectItem value={"none"}>None</SelectItem>
|
<SelectItem value={"none"}>None</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -451,37 +321,8 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
isLoading={isLoading || isLoadingById}
|
isLoading={isLoading}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
// When editing with empty password, use the existing password from DB
|
|
||||||
if (registryId && (!password || password.length === 0)) {
|
|
||||||
await testRegistryById({
|
|
||||||
registryId: registryId || "",
|
|
||||||
...(serverId && { serverId }),
|
|
||||||
})
|
|
||||||
.then((data) => {
|
|
||||||
if (data) {
|
|
||||||
toast.success("Registry Tested Successfully");
|
|
||||||
} else {
|
|
||||||
toast.error("Registry Test Failed");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error testing the registry");
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// When creating, password is required
|
|
||||||
if (!registryId && (!password || password.length === 0)) {
|
|
||||||
form.setError("password", {
|
|
||||||
type: "manual",
|
|
||||||
message: "Password is required",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// When creating or editing with new password, validate and test with provided credentials
|
|
||||||
const validationResult = AddRegistrySchema.safeParse({
|
const validationResult = AddRegistrySchema.safeParse({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
@@ -489,7 +330,6 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
registryName: "Dokploy Registry",
|
registryName: "Dokploy Registry",
|
||||||
imagePrefix,
|
imagePrefix,
|
||||||
serverId,
|
serverId,
|
||||||
isEditing: !!registryId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
@@ -505,7 +345,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
await testRegistry({
|
await testRegistry({
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
registryUrl: registryUrl || "",
|
registryUrl: registryUrl,
|
||||||
registryName: registryName,
|
registryName: registryName,
|
||||||
registryType: "cloud",
|
registryType: "cloud",
|
||||||
imagePrefix: imagePrefix,
|
imagePrefix: imagePrefix,
|
||||||
|
|||||||
@@ -122,9 +122,6 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
|
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
|
||||||
await utils.destination.all.invalidate();
|
await utils.destination.all.invalidate();
|
||||||
if (destinationId) {
|
|
||||||
await utils.destination.one.invalidate({ destinationId });
|
|
||||||
}
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const ShowGitProviders = () => {
|
|||||||
) => {
|
) => {
|
||||||
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
|
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
|
||||||
const scope = "api read_user read_repository";
|
const scope = "api read_user read_repository";
|
||||||
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scopes=${encodeURIComponent(scope)}`;
|
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
|
||||||
return authUrl;
|
return authUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
|
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -33,12 +26,13 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Select,
|
||||||
PopoverContent,
|
SelectContent,
|
||||||
PopoverTrigger,
|
SelectItem,
|
||||||
} from "@/components/ui/popover";
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
@@ -59,8 +53,6 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [modelPopoverOpen, setModelPopoverOpen] = useState(false);
|
|
||||||
const [modelSearch, setModelSearch] = useState("");
|
|
||||||
const { data, refetch } = api.ai.one.useQuery(
|
const { data, refetch } = api.ai.one.useQuery(
|
||||||
{
|
{
|
||||||
aiId: aiId || "",
|
aiId: aiId || "",
|
||||||
@@ -85,17 +77,13 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
form.reset({
|
||||||
form.reset({
|
name: data?.name ?? "",
|
||||||
name: data?.name ?? "",
|
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
|
||||||
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
|
apiKey: data?.apiKey ?? "",
|
||||||
apiKey: data?.apiKey ?? "",
|
model: data?.model ?? "",
|
||||||
model: data?.model ?? "",
|
isEnabled: data?.isEnabled ?? true,
|
||||||
isEnabled: data?.isEnabled ?? true,
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
setModelSearch("");
|
|
||||||
setModelPopoverOpen(false);
|
|
||||||
}, [aiId, form, data]);
|
}, [aiId, form, data]);
|
||||||
|
|
||||||
const apiUrl = form.watch("apiUrl");
|
const apiUrl = form.watch("apiUrl");
|
||||||
@@ -116,6 +104,14 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const apiUrl = form.watch("apiUrl");
|
||||||
|
const apiKey = form.watch("apiKey");
|
||||||
|
if (apiUrl && apiKey) {
|
||||||
|
form.setValue("model", "");
|
||||||
|
}
|
||||||
|
}, [form.watch("apiUrl"), form.watch("apiKey")]);
|
||||||
|
|
||||||
const onSubmit = async (data: Schema) => {
|
const onSubmit = async (data: Schema) => {
|
||||||
try {
|
try {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -135,16 +131,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
open={open}
|
|
||||||
onOpenChange={(isOpen) => {
|
|
||||||
setOpen(isOpen);
|
|
||||||
if (!isOpen) {
|
|
||||||
setModelSearch("");
|
|
||||||
setModelPopoverOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger className="" asChild>
|
<DialogTrigger className="" asChild>
|
||||||
{aiId ? (
|
{aiId ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -195,17 +182,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>API URL</FormLabel>
|
<FormLabel>API URL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="https://api.openai.com/v1" {...field} />
|
||||||
placeholder="https://api.openai.com/v1"
|
|
||||||
{...field}
|
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(e);
|
|
||||||
// Reset model when user changes API URL
|
|
||||||
if (form.getValues("model")) {
|
|
||||||
form.setValue("model", "");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The base URL for your AI provider's API
|
The base URL for your AI provider's API
|
||||||
@@ -228,13 +205,6 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
placeholder="sk-..."
|
placeholder="sk-..."
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
|
||||||
field.onChange(e);
|
|
||||||
// Reset model when user changes API Key
|
|
||||||
if (form.getValues("model")) {
|
|
||||||
form.setValue("model", "");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
@@ -262,89 +232,30 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="model"
|
name="model"
|
||||||
render={({ field }) => {
|
render={({ field }) => (
|
||||||
const selectedModel = models.find(
|
<FormItem>
|
||||||
(m) => m.id === field.value,
|
<FormLabel>Model</FormLabel>
|
||||||
);
|
<Select
|
||||||
const filteredModels = models.filter((model) =>
|
onValueChange={field.onChange}
|
||||||
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
value={field.value || ""}
|
||||||
);
|
>
|
||||||
|
<FormControl>
|
||||||
// Ensure selected model is always in the filtered list
|
<SelectTrigger>
|
||||||
const displayModels =
|
<SelectValue placeholder="Select a model" />
|
||||||
field.value &&
|
</SelectTrigger>
|
||||||
!filteredModels.find((m) => m.id === field.value) &&
|
</FormControl>
|
||||||
selectedModel
|
<SelectContent>
|
||||||
? [selectedModel, ...filteredModels]
|
{models.map((model) => (
|
||||||
: filteredModels;
|
<SelectItem key={model.id} value={model.id}>
|
||||||
|
{model.id}
|
||||||
return (
|
</SelectItem>
|
||||||
<FormItem>
|
))}
|
||||||
<FormLabel>Model</FormLabel>
|
</SelectContent>
|
||||||
<Popover
|
</Select>
|
||||||
open={modelPopoverOpen}
|
<FormDescription>Select an AI model to use</FormDescription>
|
||||||
onOpenChange={setModelPopoverOpen}
|
<FormMessage />
|
||||||
>
|
</FormItem>
|
||||||
<PopoverTrigger asChild>
|
)}
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between",
|
|
||||||
!field.value && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.value
|
|
||||||
? (selectedModel?.id ?? field.value)
|
|
||||||
: "Select a model"}
|
|
||||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[400px] p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search models..."
|
|
||||||
value={modelSearch}
|
|
||||||
onValueChange={setModelSearch}
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No models found.</CommandEmpty>
|
|
||||||
{displayModels.map((model) => {
|
|
||||||
const isSelected = field.value === model.id;
|
|
||||||
return (
|
|
||||||
<CommandItem
|
|
||||||
key={model.id}
|
|
||||||
value={model.id}
|
|
||||||
onSelect={() => {
|
|
||||||
field.onChange(model.id);
|
|
||||||
setModelPopoverOpen(false);
|
|
||||||
setModelSearch("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
isSelected
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{model.id}
|
|
||||||
</CommandItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormDescription>
|
|
||||||
Select an AI model to use
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||||
AlertTriangle,
|
|
||||||
Mail,
|
|
||||||
PenBoxIcon,
|
|
||||||
PlusIcon,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -50,7 +44,6 @@ const notificationBaseSchema = z.object({
|
|||||||
appDeploy: z.boolean().default(false),
|
appDeploy: z.boolean().default(false),
|
||||||
appBuildError: z.boolean().default(false),
|
appBuildError: z.boolean().default(false),
|
||||||
databaseBackup: z.boolean().default(false),
|
databaseBackup: z.boolean().default(false),
|
||||||
volumeBackup: z.boolean().default(false),
|
|
||||||
dokployRestart: z.boolean().default(false),
|
dokployRestart: z.boolean().default(false),
|
||||||
dockerCleanup: z.boolean().default(false),
|
dockerCleanup: z.boolean().default(false),
|
||||||
serverThreshold: z.boolean().default(false),
|
serverThreshold: z.boolean().default(false),
|
||||||
@@ -110,25 +103,10 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
|||||||
type: z.literal("ntfy"),
|
type: z.literal("ntfy"),
|
||||||
serverUrl: z.string().min(1, { message: "Server URL is required" }),
|
serverUrl: z.string().min(1, { message: "Server URL is required" }),
|
||||||
topic: z.string().min(1, { message: "Topic is required" }),
|
topic: z.string().min(1, { message: "Topic is required" }),
|
||||||
accessToken: z.string().optional(),
|
accessToken: z.string().min(1, { message: "Access Token is required" }),
|
||||||
priority: z.number().min(1).max(5).default(3),
|
priority: z.number().min(1).max(5).default(3),
|
||||||
})
|
})
|
||||||
.merge(notificationBaseSchema),
|
.merge(notificationBaseSchema),
|
||||||
z
|
|
||||||
.object({
|
|
||||||
type: z.literal("custom"),
|
|
||||||
endpoint: z.string().min(1, { message: "Endpoint URL is required" }),
|
|
||||||
headers: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
key: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.default([]),
|
|
||||||
})
|
|
||||||
.merge(notificationBaseSchema),
|
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("lark"),
|
type: z.literal("lark"),
|
||||||
@@ -166,10 +144,6 @@ export const notificationsMap = {
|
|||||||
icon: <NtfyIcon />,
|
icon: <NtfyIcon />,
|
||||||
label: "ntfy",
|
label: "ntfy",
|
||||||
},
|
},
|
||||||
custom: {
|
|
||||||
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
|
|
||||||
label: "Custom",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
||||||
@@ -205,13 +179,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
api.notification.testNtfyConnection.useMutation();
|
api.notification.testNtfyConnection.useMutation();
|
||||||
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
||||||
api.notification.testLarkConnection.useMutation();
|
api.notification.testLarkConnection.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
|
||||||
api.notification.testCustomConnection.useMutation();
|
|
||||||
|
|
||||||
const customMutation = notificationId
|
|
||||||
? api.notification.updateCustom.useMutation()
|
|
||||||
: api.notification.createCustom.useMutation();
|
|
||||||
const slackMutation = notificationId
|
const slackMutation = notificationId
|
||||||
? api.notification.updateSlack.useMutation()
|
? api.notification.updateSlack.useMutation()
|
||||||
: api.notification.createSlack.useMutation();
|
: api.notification.createSlack.useMutation();
|
||||||
@@ -250,15 +217,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
name: "toAddresses" as never,
|
name: "toAddresses" as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
|
||||||
fields: headerFields,
|
|
||||||
append: appendHeader,
|
|
||||||
remove: removeHeader,
|
|
||||||
} = useFieldArray({
|
|
||||||
control: form.control,
|
|
||||||
name: "headers" as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type === "email" && fields.length === 0) {
|
if (type === "email" && fields.length === 0) {
|
||||||
append("");
|
append("");
|
||||||
@@ -273,7 +231,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
volumeBackup: notification.volumeBackup,
|
|
||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
webhookUrl: notification.slack?.webhookUrl,
|
webhookUrl: notification.slack?.webhookUrl,
|
||||||
channel: notification.slack?.channel || "",
|
channel: notification.slack?.channel || "",
|
||||||
@@ -287,7 +244,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
volumeBackup: notification.volumeBackup,
|
|
||||||
botToken: notification.telegram?.botToken,
|
botToken: notification.telegram?.botToken,
|
||||||
messageThreadId: notification.telegram?.messageThreadId || "",
|
messageThreadId: notification.telegram?.messageThreadId || "",
|
||||||
chatId: notification.telegram?.chatId,
|
chatId: notification.telegram?.chatId,
|
||||||
@@ -302,7 +258,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
volumeBackup: notification.volumeBackup,
|
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
webhookUrl: notification.discord?.webhookUrl,
|
webhookUrl: notification.discord?.webhookUrl,
|
||||||
decoration: notification.discord?.decoration || undefined,
|
decoration: notification.discord?.decoration || undefined,
|
||||||
@@ -316,7 +271,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
volumeBackup: notification.volumeBackup,
|
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
smtpServer: notification.email?.smtpServer,
|
smtpServer: notification.email?.smtpServer,
|
||||||
smtpPort: notification.email?.smtpPort,
|
smtpPort: notification.email?.smtpPort,
|
||||||
@@ -334,7 +288,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
volumeBackup: notification.volumeBackup,
|
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
appToken: notification.gotify?.appToken,
|
appToken: notification.gotify?.appToken,
|
||||||
decoration: notification.gotify?.decoration || undefined,
|
decoration: notification.gotify?.decoration || undefined,
|
||||||
@@ -349,9 +302,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
volumeBackup: notification.volumeBackup,
|
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
accessToken: notification.ntfy?.accessToken || "",
|
accessToken: notification.ntfy?.accessToken,
|
||||||
topic: notification.ntfy?.topic,
|
topic: notification.ntfy?.topic,
|
||||||
priority: notification.ntfy?.priority,
|
priority: notification.ntfy?.priority,
|
||||||
serverUrl: notification.ntfy?.serverUrl,
|
serverUrl: notification.ntfy?.serverUrl,
|
||||||
@@ -369,28 +321,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
webhookUrl: notification.lark?.webhookUrl,
|
webhookUrl: notification.lark?.webhookUrl,
|
||||||
name: notification.name,
|
name: notification.name,
|
||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
volumeBackup: notification.volumeBackup,
|
|
||||||
serverThreshold: notification.serverThreshold,
|
|
||||||
});
|
|
||||||
} else if (notification.notificationType === "custom") {
|
|
||||||
form.reset({
|
|
||||||
appBuildError: notification.appBuildError,
|
|
||||||
appDeploy: notification.appDeploy,
|
|
||||||
dokployRestart: notification.dokployRestart,
|
|
||||||
databaseBackup: notification.databaseBackup,
|
|
||||||
type: notification.notificationType,
|
|
||||||
endpoint: notification.custom?.endpoint || "",
|
|
||||||
headers: notification.custom?.headers
|
|
||||||
? Object.entries(notification.custom.headers).map(
|
|
||||||
([key, value]) => ({
|
|
||||||
key,
|
|
||||||
value,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: [],
|
|
||||||
name: notification.name,
|
|
||||||
volumeBackup: notification.volumeBackup,
|
|
||||||
dockerCleanup: notification.dockerCleanup,
|
|
||||||
serverThreshold: notification.serverThreshold,
|
serverThreshold: notification.serverThreshold,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -407,7 +337,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
gotify: gotifyMutation,
|
gotify: gotifyMutation,
|
||||||
ntfy: ntfyMutation,
|
ntfy: ntfyMutation,
|
||||||
lark: larkMutation,
|
lark: larkMutation,
|
||||||
custom: customMutation,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: NotificationSchema) => {
|
const onSubmit = async (data: NotificationSchema) => {
|
||||||
@@ -416,7 +345,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy,
|
appDeploy,
|
||||||
dokployRestart,
|
dokployRestart,
|
||||||
databaseBackup,
|
databaseBackup,
|
||||||
volumeBackup,
|
|
||||||
dockerCleanup,
|
dockerCleanup,
|
||||||
serverThreshold,
|
serverThreshold,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -427,7 +355,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
volumeBackup: volumeBackup,
|
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
channel: data.channel,
|
channel: data.channel,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -442,7 +369,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
volumeBackup: volumeBackup,
|
|
||||||
botToken: data.botToken,
|
botToken: data.botToken,
|
||||||
messageThreadId: data.messageThreadId || "",
|
messageThreadId: data.messageThreadId || "",
|
||||||
chatId: data.chatId,
|
chatId: data.chatId,
|
||||||
@@ -458,7 +384,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
volumeBackup: volumeBackup,
|
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
decoration: data.decoration,
|
decoration: data.decoration,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -473,7 +398,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
volumeBackup: volumeBackup,
|
|
||||||
smtpServer: data.smtpServer,
|
smtpServer: data.smtpServer,
|
||||||
smtpPort: data.smtpPort,
|
smtpPort: data.smtpPort,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
@@ -492,7 +416,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
volumeBackup: volumeBackup,
|
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: data.serverUrl,
|
||||||
appToken: data.appToken,
|
appToken: data.appToken,
|
||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
@@ -508,9 +431,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
volumeBackup: volumeBackup,
|
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: data.serverUrl,
|
||||||
accessToken: data.accessToken || "",
|
accessToken: data.accessToken,
|
||||||
topic: data.topic,
|
topic: data.topic,
|
||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -524,7 +446,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
volumeBackup: volumeBackup,
|
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
dockerCleanup: dockerCleanup,
|
dockerCleanup: dockerCleanup,
|
||||||
@@ -532,33 +453,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
larkId: notification?.larkId || "",
|
larkId: notification?.larkId || "",
|
||||||
serverThreshold: serverThreshold,
|
serverThreshold: serverThreshold,
|
||||||
});
|
});
|
||||||
} else if (data.type === "custom") {
|
|
||||||
// Convert headers array to object
|
|
||||||
const headersRecord =
|
|
||||||
data.headers && data.headers.length > 0
|
|
||||||
? data.headers.reduce(
|
|
||||||
(acc, { key, value }) => {
|
|
||||||
if (key.trim()) acc[key] = value;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string>,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
promise = customMutation.mutateAsync({
|
|
||||||
appBuildError: appBuildError,
|
|
||||||
appDeploy: appDeploy,
|
|
||||||
dokployRestart: dokployRestart,
|
|
||||||
databaseBackup: databaseBackup,
|
|
||||||
volumeBackup: volumeBackup,
|
|
||||||
endpoint: data.endpoint,
|
|
||||||
headers: headersRecord,
|
|
||||||
name: data.name,
|
|
||||||
dockerCleanup: dockerCleanup,
|
|
||||||
serverThreshold: serverThreshold,
|
|
||||||
notificationId: notificationId || "",
|
|
||||||
customId: notification?.customId || "",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (promise) {
|
if (promise) {
|
||||||
@@ -1107,12 +1001,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="AzxcvbnmKjhgfdsa..."
|
placeholder="AzxcvbnmKjhgfdsa..."
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
Optional. Leave blank for public topics.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -1149,92 +1039,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{type === "custom" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="endpoint"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Webhook URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="https://api.example.com/webhook"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
The URL where POST requests will be sent with
|
|
||||||
notification data.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<FormLabel>Headers</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Optional. Custom headers for your POST request (e.g.,
|
|
||||||
Authorization, Content-Type).
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{headerFields.map((field, index) => (
|
|
||||||
<div
|
|
||||||
key={field.id}
|
|
||||||
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`headers.${index}.key` as never}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Key" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`headers.${index}.value` as never}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex-[2]">
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Value" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeHeader(index)}
|
|
||||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => appendHeader({ key: "", value: "" })}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4 mr-2" />
|
|
||||||
Add header
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{type === "lark" && (
|
{type === "lark" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1325,27 +1130,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="volumeBackup"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Volume Backup</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Trigger the action when a volume backup is created.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="dockerCleanup"
|
name="dockerCleanup"
|
||||||
@@ -1427,82 +1211,58 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
isLoadingEmail ||
|
isLoadingEmail ||
|
||||||
isLoadingGotify ||
|
isLoadingGotify ||
|
||||||
isLoadingNtfy ||
|
isLoadingNtfy ||
|
||||||
isLoadingLark ||
|
isLoadingLark
|
||||||
isLoadingCustom
|
|
||||||
}
|
}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const isValid = await form.trigger();
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
const data = form.getValues();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (data.type === "slack") {
|
if (type === "slack") {
|
||||||
await testSlackConnection({
|
await testSlackConnection({
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: form.getValues("webhookUrl"),
|
||||||
channel: data.channel,
|
channel: form.getValues("channel"),
|
||||||
});
|
});
|
||||||
} else if (data.type === "telegram") {
|
} else if (type === "telegram") {
|
||||||
await testTelegramConnection({
|
await testTelegramConnection({
|
||||||
botToken: data.botToken,
|
botToken: form.getValues("botToken"),
|
||||||
chatId: data.chatId,
|
chatId: form.getValues("chatId"),
|
||||||
messageThreadId: data.messageThreadId || "",
|
messageThreadId: form.getValues("messageThreadId") || "",
|
||||||
});
|
});
|
||||||
} else if (data.type === "discord") {
|
} else if (type === "discord") {
|
||||||
await testDiscordConnection({
|
await testDiscordConnection({
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: form.getValues("webhookUrl"),
|
||||||
decoration: data.decoration,
|
decoration: form.getValues("decoration"),
|
||||||
});
|
});
|
||||||
} else if (data.type === "email") {
|
} else if (type === "email") {
|
||||||
await testEmailConnection({
|
await testEmailConnection({
|
||||||
smtpServer: data.smtpServer,
|
smtpServer: form.getValues("smtpServer"),
|
||||||
smtpPort: data.smtpPort,
|
smtpPort: form.getValues("smtpPort"),
|
||||||
username: data.username,
|
username: form.getValues("username"),
|
||||||
password: data.password,
|
password: form.getValues("password"),
|
||||||
fromAddress: data.fromAddress,
|
toAddresses: form.getValues("toAddresses"),
|
||||||
toAddresses: data.toAddresses,
|
fromAddress: form.getValues("fromAddress"),
|
||||||
});
|
});
|
||||||
} else if (data.type === "gotify") {
|
} else if (type === "gotify") {
|
||||||
await testGotifyConnection({
|
await testGotifyConnection({
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: form.getValues("serverUrl"),
|
||||||
appToken: data.appToken,
|
appToken: form.getValues("appToken"),
|
||||||
priority: data.priority,
|
priority: form.getValues("priority"),
|
||||||
decoration: data.decoration,
|
decoration: form.getValues("decoration"),
|
||||||
});
|
});
|
||||||
} else if (data.type === "ntfy") {
|
} else if (type === "ntfy") {
|
||||||
await testNtfyConnection({
|
await testNtfyConnection({
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: form.getValues("serverUrl"),
|
||||||
topic: data.topic,
|
topic: form.getValues("topic"),
|
||||||
accessToken: data.accessToken || "",
|
accessToken: form.getValues("accessToken"),
|
||||||
priority: data.priority,
|
priority: form.getValues("priority"),
|
||||||
});
|
});
|
||||||
} else if (data.type === "lark") {
|
} else if (type === "lark") {
|
||||||
await testLarkConnection({
|
await testLarkConnection({
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: form.getValues("webhookUrl"),
|
||||||
});
|
|
||||||
} else if (data.type === "custom") {
|
|
||||||
const headersRecord =
|
|
||||||
data.headers && data.headers.length > 0
|
|
||||||
? data.headers.reduce(
|
|
||||||
(acc, { key, value }) => {
|
|
||||||
if (key.trim()) acc[key] = value;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string>,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
await testCustomConnection({
|
|
||||||
endpoint: data.endpoint,
|
|
||||||
headers: headersRecord,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
toast.success("Connection Success");
|
toast.success("Connection Success");
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error(
|
toast.error("Error testing the provider");
|
||||||
`Error testing the provider: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Bell, Loader2, Mail, PenBoxIcon, Trash2 } from "lucide-react";
|
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
DiscordIcon,
|
DiscordIcon,
|
||||||
@@ -96,11 +96,6 @@ export const ShowNotifications = () => {
|
|||||||
<NtfyIcon className="size-6" />
|
<NtfyIcon className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{notification.notificationType === "custom" && (
|
|
||||||
<div className="flex items-center justify-center rounded-lg ">
|
|
||||||
<PenBoxIcon className="size-6 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{notification.notificationType === "lark" && (
|
{notification.notificationType === "lark" && (
|
||||||
<div className="flex items-center justify-center rounded-lg">
|
<div className="flex items-center justify-center rounded-lg">
|
||||||
<LarkIcon className="size-7 text-muted-foreground" />
|
<LarkIcon className="size-7 text-muted-foreground" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Loader2, Palette, User } from "lucide-react";
|
import { Loader2, User } from "lucide-react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, 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";
|
||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
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 { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils";
|
|
||||||
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { Configure2FA } from "./configure-2fa";
|
import { Configure2FA } from "./configure-2fa";
|
||||||
@@ -42,7 +41,6 @@ const profileSchema = z.object({
|
|||||||
currentPassword: z.string().nullable(),
|
currentPassword: z.string().nullable(),
|
||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
lastName: z.string().optional(),
|
|
||||||
allowImpersonation: z.boolean().optional().default(false),
|
allowImpersonation: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,7 +73,6 @@ export const ProfileForm = () => {
|
|||||||
} = api.user.update.useMutation();
|
} = api.user.update.useMutation();
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const availableAvatars = useMemo(() => {
|
const availableAvatars = useMemo(() => {
|
||||||
if (gravatarHash === null) return randomImages;
|
if (gravatarHash === null) return randomImages;
|
||||||
@@ -91,8 +88,7 @@ export const ProfileForm = () => {
|
|||||||
image: data?.user?.image || "",
|
image: data?.user?.image || "",
|
||||||
currentPassword: "",
|
currentPassword: "",
|
||||||
allowImpersonation: data?.user?.allowImpersonation || false,
|
allowImpersonation: data?.user?.allowImpersonation || false,
|
||||||
name: data?.user?.firstName || "",
|
name: data?.user?.name || "",
|
||||||
lastName: data?.user?.lastName || "",
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
});
|
});
|
||||||
@@ -106,8 +102,7 @@ export const ProfileForm = () => {
|
|||||||
image: data?.user?.image || "",
|
image: data?.user?.image || "",
|
||||||
currentPassword: form.getValues("currentPassword") || "",
|
currentPassword: form.getValues("currentPassword") || "",
|
||||||
allowImpersonation: data?.user?.allowImpersonation,
|
allowImpersonation: data?.user?.allowImpersonation,
|
||||||
name: data?.user?.firstName || "",
|
name: data?.user?.name || "",
|
||||||
lastName: data?.user?.lastName || "",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepValues: true,
|
keepValues: true,
|
||||||
@@ -132,7 +127,6 @@ export const ProfileForm = () => {
|
|||||||
currentPassword: values.currentPassword || undefined,
|
currentPassword: values.currentPassword || undefined,
|
||||||
allowImpersonation: values.allowImpersonation,
|
allowImpersonation: values.allowImpersonation,
|
||||||
name: values.name || undefined,
|
name: values.name || undefined,
|
||||||
lastName: values.lastName || undefined,
|
|
||||||
});
|
});
|
||||||
await refetch();
|
await refetch();
|
||||||
toast.success("Profile Updated");
|
toast.success("Profile Updated");
|
||||||
@@ -142,7 +136,6 @@ export const ProfileForm = () => {
|
|||||||
image: values.image,
|
image: values.image,
|
||||||
currentPassword: "",
|
currentPassword: "",
|
||||||
name: values.name || "",
|
name: values.name || "",
|
||||||
lastName: values.lastName || "",
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Error updating the profile");
|
toast.error("Error updating the profile");
|
||||||
@@ -187,22 +180,9 @@ export const ProfileForm = () => {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>First Name</FormLabel>
|
<FormLabel>Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="John" {...field} />
|
<Input placeholder="Name" {...field} />
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="lastName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Last Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Doe" {...field} />
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -276,8 +256,16 @@ export const ProfileForm = () => {
|
|||||||
onValueChange={(e) => {
|
onValueChange={(e) => {
|
||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
}}
|
}}
|
||||||
defaultValue={getAvatarType(field.value)}
|
defaultValue={
|
||||||
value={getAvatarType(field.value)}
|
field.value?.startsWith("data:")
|
||||||
|
? "upload"
|
||||||
|
: field.value
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
field.value?.startsWith("data:")
|
||||||
|
? "upload"
|
||||||
|
: field.value
|
||||||
|
}
|
||||||
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
||||||
>
|
>
|
||||||
<FormItem key="no-avatar">
|
<FormItem key="no-avatar">
|
||||||
@@ -292,7 +280,7 @@ export const ProfileForm = () => {
|
|||||||
<Avatar className="default-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform">
|
<Avatar className="default-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform">
|
||||||
<AvatarFallback className="rounded-lg">
|
<AvatarFallback className="rounded-lg">
|
||||||
{getFallbackAvatarInitials(
|
{getFallbackAvatarInitials(
|
||||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
|
data?.user?.name,
|
||||||
)}
|
)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@@ -364,40 +352,6 @@ export const ProfileForm = () => {
|
|||||||
/>
|
/>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem key="color-avatar">
|
|
||||||
<FormLabel className="[&:has([data-state=checked])>.color-avatar]:border-primary [&:has([data-state=checked])>.color-avatar]:border-1 [&:has([data-state=checked])>.color-avatar]:p-px cursor-pointer relative">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem
|
|
||||||
value="color"
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<div
|
|
||||||
className="color-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-colors flex items-center justify-center overflow-hidden cursor-pointer"
|
|
||||||
style={{
|
|
||||||
backgroundColor: isSolidColorAvatar(
|
|
||||||
field.value,
|
|
||||||
)
|
|
||||||
? field.value
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
onClick={() =>
|
|
||||||
colorInputRef.current?.click()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{!isSolidColorAvatar(field.value) && (
|
|
||||||
<Palette className="h-5 w-5 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={colorInputRef}
|
|
||||||
type="color"
|
|
||||||
className="absolute opacity-0 pointer-events-none w-12 h-12 top-0 left-0"
|
|
||||||
value={field.value}
|
|
||||||
onChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
{availableAvatars.map((image) => (
|
{availableAvatars.map((image) => (
|
||||||
<FormItem key={image}>
|
<FormItem key={image}>
|
||||||
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
|
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { api } from "@/utils/api";
|
|||||||
import { ShowModalLogs } from "../../web-server/show-modal-logs";
|
import { ShowModalLogs } from "../../web-server/show-modal-logs";
|
||||||
import { TerminalModal } from "../../web-server/terminal-modal";
|
import { TerminalModal } from "../../web-server/terminal-modal";
|
||||||
import { GPUSupportModal } from "../gpu-support-modal";
|
import { GPUSupportModal } from "../gpu-support-modal";
|
||||||
|
import { ChangeConcurrencyModal } from "../change-concurrency-modal";
|
||||||
|
|
||||||
export const ShowDokployActions = () => {
|
export const ShowDokployActions = () => {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
@@ -101,6 +102,14 @@ export const ShowDokployActions = () => {
|
|||||||
>
|
>
|
||||||
Reload Redis
|
Reload Redis
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<ChangeConcurrencyModal>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Change Concurrency
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</ChangeConcurrencyModal>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Activity } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -9,36 +7,28 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { ShowStorageActions } from "./show-storage-actions";
|
import { ShowStorageActions } from "./show-storage-actions";
|
||||||
import { ShowTraefikActions } from "./show-traefik-actions";
|
import { ShowTraefikActions } from "./show-traefik-actions";
|
||||||
import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
|
import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
|
||||||
|
import { ChangeConcurrencyModal } from "../change-concurrency-modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
asButton?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
|
export const ShowServerActions = ({ serverId }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
{asButton ? (
|
<DialogTrigger asChild>
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
|
||||||
<Activity className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
onSelect={(e) => {
|
onSelect={(e) => e.preventDefault()}
|
||||||
e.preventDefault();
|
|
||||||
setIsOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
View Actions
|
View Actions
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-xl">
|
<DialogContent className="sm:max-w-xl">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
||||||
@@ -49,6 +39,16 @@ export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
|
|||||||
<ShowTraefikActions serverId={serverId} />
|
<ShowTraefikActions serverId={serverId} />
|
||||||
<ShowStorageActions serverId={serverId} />
|
<ShowStorageActions serverId={serverId} />
|
||||||
<ToggleDockerCleanup serverId={serverId} />
|
<ToggleDockerCleanup serverId={serverId} />
|
||||||
|
<div className="col-span-2">
|
||||||
|
<ChangeConcurrencyModal
|
||||||
|
serverId={serverId}
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
Change Concurrency
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
serverId: serverId,
|
serverId: serverId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Cleaning in progress... Please wait");
|
toast.success("Cleaned all");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error cleaning all");
|
toast.error("Error cleaning all");
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -87,26 +85,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</EditTraefikEnv>
|
</EditTraefikEnv>
|
||||||
|
|
||||||
<DialogAction
|
<DropdownMenuItem
|
||||||
title={
|
|
||||||
haveTraefikDashboardPortEnabled
|
|
||||||
? "Disable Traefik Dashboard"
|
|
||||||
: "Enable Traefik Dashboard"
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
<div className="space-y-4">
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
The Traefik container will be recreated from scratch. This
|
|
||||||
means the container will be deleted and created again, which
|
|
||||||
may cause downtime in your applications.
|
|
||||||
</AlertBlock>
|
|
||||||
<p>
|
|
||||||
Are you sure you want to{" "}
|
|
||||||
{haveTraefikDashboardPortEnabled ? "disable" : "enable"} the
|
|
||||||
Traefik dashboard?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await toggleDashboard({
|
await toggleDashboard({
|
||||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||||
@@ -118,26 +97,14 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
);
|
);
|
||||||
refetchDashboard();
|
refetchDashboard();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch(() => {});
|
||||||
const errorMessage =
|
|
||||||
error?.message ||
|
|
||||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
|
||||||
toast.error(errorMessage);
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
disabled={toggleDashboardIsLoading}
|
className="w-full cursor-pointer space-x-3"
|
||||||
type="default"
|
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<span>
|
||||||
onSelect={(e) => e.preventDefault()}
|
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
|
||||||
className="w-full cursor-pointer space-x-3"
|
</span>
|
||||||
>
|
</DropdownMenuItem>
|
||||||
<span>
|
|
||||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
|
|
||||||
Dashboard
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogAction>
|
|
||||||
<ManageTraefikPorts serverId={serverId}>
|
<ManageTraefikPorts serverId={serverId}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
|||||||
@@ -7,12 +7,9 @@ interface Props {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery(
|
const { data, refetch } = api.user.get.useQuery(undefined, {
|
||||||
undefined,
|
enabled: !serverId,
|
||||||
{
|
});
|
||||||
enabled: !serverId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
|
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -25,7 +22,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
const enabled = serverId
|
const enabled = serverId
|
||||||
? server?.enableDockerCleanup
|
? server?.enableDockerCleanup
|
||||||
: data?.enableDockerCleanup;
|
: data?.user.enableDockerCleanup;
|
||||||
|
|
||||||
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
|
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
|
||||||
|
|
||||||
@@ -33,10 +30,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
|||||||
try {
|
try {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
enableDockerCleanup: checked,
|
enableDockerCleanup: checked,
|
||||||
...(serverId && { serverId }),
|
serverId: serverId,
|
||||||
} as {
|
|
||||||
enableDockerCleanup: boolean;
|
|
||||||
serverId?: string;
|
|
||||||
});
|
});
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
await refetchServer();
|
await refetchServer();
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { InfoIcon, Loader2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
serverId?: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChangeConcurrencyModal = ({ serverId, trigger }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [concurrency, setConcurrency] = useState<number | "">("");
|
||||||
|
|
||||||
|
const { data, isLoading: isLoadingCurrent } =
|
||||||
|
api.settings.getDeploymentConcurrency.useQuery(
|
||||||
|
{ serverId },
|
||||||
|
{
|
||||||
|
enabled: isOpen,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (concurrency === "") {
|
||||||
|
setConcurrency(data.concurrency);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } =
|
||||||
|
api.settings.setDeploymentConcurrency.useMutation();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (
|
||||||
|
typeof concurrency !== "number" ||
|
||||||
|
concurrency < 1 ||
|
||||||
|
concurrency > 20
|
||||||
|
) {
|
||||||
|
toast.error("Concurrency must be between 1 and 20");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await mutateAsync({ concurrency, serverId });
|
||||||
|
if (result.clearedBuilds > 0) {
|
||||||
|
toast.warning(
|
||||||
|
`Concurrency updated. ${result.clearedBuilds} pending build${result.clearedBuilds > 1 ? "s were" : " was"} cancelled.`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success("Concurrency updated successfully");
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to update concurrency");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverType = serverId ? "Remote Server" : "Dokploy Server";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger || (
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Change Concurrency
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Deployment Concurrency - {serverType}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure how many deployments can run simultaneously on this
|
||||||
|
server.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="concurrency">Concurrency</Label>
|
||||||
|
<Input
|
||||||
|
id="concurrency"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={concurrency}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setConcurrency(value === "" ? "" : Number.parseInt(value, 10));
|
||||||
|
}}
|
||||||
|
placeholder="Enter concurrency (1-20)"
|
||||||
|
disabled={isLoading || isLoadingCurrent}
|
||||||
|
/>
|
||||||
|
{isLoadingCurrent && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading current concurrency...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoadingCurrent && data && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Current: {data.concurrency}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Alert>
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertDescription className="text-sm">
|
||||||
|
<div className="space-y-1 mt-1">
|
||||||
|
<p>
|
||||||
|
<strong>Default:</strong> 1 deployment at a time
|
||||||
|
(sequential)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Higher values:</strong> More deployments in
|
||||||
|
parallel, but will use more RAM and CPU resources.
|
||||||
|
</p>
|
||||||
|
{serverId && (
|
||||||
|
<p className="text-muted-foreground text-xs mt-2">
|
||||||
|
This setting applies to deployments on this remote server.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!serverId && (
|
||||||
|
<p className="text-muted-foreground text-xs mt-2">
|
||||||
|
This setting applies to deployments on the Dokploy server.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertDescription className="text-sm font-medium">
|
||||||
|
⚠️ <strong>Warning:</strong> Changing concurrency will cancel all
|
||||||
|
pending builds. Currently running builds will continue, but
|
||||||
|
queued builds will be cancelled.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading || isLoadingCurrent}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Updating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Update Concurrency"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Pencil, PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -52,17 +52,15 @@ const Schema = z.object({
|
|||||||
sshKeyId: z.string().min(1, {
|
sshKeyId: z.string().min(1, {
|
||||||
message: "SSH Key is required",
|
message: "SSH Key is required",
|
||||||
}),
|
}),
|
||||||
serverType: z.enum(["deploy", "build"]).default("deploy"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type Schema = z.infer<typeof Schema>;
|
type Schema = z.infer<typeof Schema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
asButton?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
export const HandleServers = ({ serverId }: Props) => {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -91,7 +89,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
port: 22,
|
port: 22,
|
||||||
username: "root",
|
username: "root",
|
||||||
sshKeyId: "",
|
sshKeyId: "",
|
||||||
serverType: "deploy",
|
|
||||||
},
|
},
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
});
|
});
|
||||||
@@ -104,7 +101,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
port: data?.port || 22,
|
port: data?.port || 22,
|
||||||
username: data?.username || "root",
|
username: data?.username || "root",
|
||||||
sshKeyId: data?.sshKeyId || "",
|
sshKeyId: data?.sshKeyId || "",
|
||||||
serverType: data?.serverType || "deploy",
|
|
||||||
});
|
});
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||||
|
|
||||||
@@ -120,7 +116,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
port: data.port || 22,
|
port: data.port || 22,
|
||||||
username: data.username || "root",
|
username: data.username || "root",
|
||||||
sshKeyId: data.sshKeyId || "",
|
sshKeyId: data.sshKeyId || "",
|
||||||
serverType: data.serverType || "deploy",
|
|
||||||
serverId: serverId || "",
|
serverId: serverId || "",
|
||||||
})
|
})
|
||||||
.then(async (_data) => {
|
.then(async (_data) => {
|
||||||
@@ -138,32 +133,21 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
{serverId ? (
|
<DialogTrigger asChild>
|
||||||
asButton ? (
|
{serverId ? (
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
|
||||||
<Pencil className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
onSelect={(e) => {
|
onSelect={(e) => e.preventDefault()}
|
||||||
e.preventDefault();
|
|
||||||
setIsOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Edit Server
|
Edit Server
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)
|
) : (
|
||||||
) : (
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="cursor-pointer space-x-3">
|
<Button className="cursor-pointer space-x-3">
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
Create Server
|
Create Server
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
)}
|
||||||
)}
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-3xl ">
|
<DialogContent className="sm:max-w-3xl ">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
||||||
@@ -282,50 +266,6 @@ export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="serverType"
|
|
||||||
render={({ field }) => {
|
|
||||||
const serverTypeValue = form.watch("serverType");
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Server Type</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a server type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem value="deploy">Deploy Server</SelectItem>
|
|
||||||
<SelectItem value="build">Build Server</SelectItem>
|
|
||||||
<SelectLabel>Server Type</SelectLabel>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
{serverTypeValue === "deploy" && (
|
|
||||||
<AlertBlock type="info" className="mt-2">
|
|
||||||
Deploy servers are used to run your applications,
|
|
||||||
databases, and services. They handle the deployment and
|
|
||||||
execution of your projects.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
{serverTypeValue === "build" && (
|
|
||||||
<AlertBlock type="info" className="mt-2">
|
|
||||||
Build servers are dedicated to building your
|
|
||||||
applications. They handle the compilation and build
|
|
||||||
process, offloading this work from your deployment
|
|
||||||
servers. Build servers won't appear in deployment
|
|
||||||
options.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="sshKeyId"
|
name="sshKeyId"
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const Schema = z.object({
|
|||||||
type Schema = z.infer<typeof Schema>;
|
type Schema = z.infer<typeof Schema>;
|
||||||
|
|
||||||
export const SetupMonitoring = ({ serverId }: Props) => {
|
export const SetupMonitoring = ({ serverId }: Props) => {
|
||||||
const { data: serverData } = serverId
|
const { data } = serverId
|
||||||
? api.server.one.useQuery(
|
? api.server.one.useQuery(
|
||||||
{
|
{
|
||||||
serverId: serverId || "",
|
serverId: serverId || "",
|
||||||
@@ -89,14 +89,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
|
|||||||
enabled: !!serverId,
|
enabled: !!serverId,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: { data: null };
|
: api.user.getServerMetrics.useQuery();
|
||||||
|
|
||||||
const { data: webServerSettings } =
|
|
||||||
api.settings.getWebServerSettings.useQuery(undefined, {
|
|
||||||
enabled: !serverId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = serverId ? serverData : webServerSettings;
|
|
||||||
|
|
||||||
const url = useUrl();
|
const url = useUrl();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { CopyIcon, ExternalLinkIcon, ServerIcon, Settings } from "lucide-react";
|
import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -35,10 +36,9 @@ import { ValidateServer } from "./validate-server";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
asButton?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
export const SetupServer = ({ serverId }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: server } = api.server.one.useQuery(
|
const { data: server } = api.server.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -51,7 +51,6 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
|||||||
|
|
||||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const isBuildServer = server?.serverType === "build";
|
|
||||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
const [isDeploying, setIsDeploying] = useState(false);
|
const [isDeploying, setIsDeploying] = useState(false);
|
||||||
@@ -81,23 +80,14 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
{asButton ? (
|
<DialogTrigger asChild>
|
||||||
<DialogTrigger asChild>
|
<DropdownMenuItem
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
size="sm"
|
onSelect={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
|
||||||
setIsOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Setup Server <Settings className="size-4" />
|
Setup Server
|
||||||
</Button>
|
</DropdownMenuItem>
|
||||||
)}
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-4xl ">
|
<DialogContent className="sm:max-w-4xl ">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -127,26 +117,17 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
|||||||
<TabsList
|
<TabsList
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid w-[700px]",
|
"grid w-[700px]",
|
||||||
isBuildServer
|
isCloud ? "grid-cols-6" : "grid-cols-5",
|
||||||
? "grid-cols-3"
|
|
||||||
: isCloud
|
|
||||||
? "grid-cols-6"
|
|
||||||
: "grid-cols-5",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
<TabsTrigger value="validate">Validate</TabsTrigger>
|
<TabsTrigger value="validate">Validate</TabsTrigger>
|
||||||
|
<TabsTrigger value="audit">Security</TabsTrigger>
|
||||||
{!isBuildServer && (
|
{isCloud && (
|
||||||
<>
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
<TabsTrigger value="audit">Security</TabsTrigger>
|
|
||||||
{isCloud && (
|
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
|
||||||
)}
|
|
||||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="ssh-keys"
|
value="ssh-keys"
|
||||||
@@ -330,36 +311,32 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
|||||||
<ValidateServer serverId={serverId} />
|
<ValidateServer serverId={serverId} />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{!isBuildServer && (
|
<TabsContent
|
||||||
<>
|
value="audit"
|
||||||
<TabsContent
|
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
value="audit"
|
>
|
||||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||||
>
|
<SecurityAudit serverId={serverId} />
|
||||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
</div>
|
||||||
<SecurityAudit serverId={serverId} />
|
</TabsContent>
|
||||||
</div>
|
<TabsContent
|
||||||
</TabsContent>
|
value="monitoring"
|
||||||
<TabsContent
|
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
value="monitoring"
|
>
|
||||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
<div className="flex flex-col gap-2 text-sm pt-3">
|
||||||
>
|
<div className="rounded-xl bg-background shadow-md border">
|
||||||
<div className="flex flex-col gap-2 text-sm pt-3">
|
<SetupMonitoring serverId={serverId} />
|
||||||
<div className="rounded-xl bg-background shadow-md border">
|
</div>
|
||||||
<SetupMonitoring serverId={serverId} />
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
</div>
|
<TabsContent
|
||||||
</TabsContent>
|
value="gpu-setup"
|
||||||
<TabsContent
|
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
value="gpu-setup"
|
>
|
||||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||||
>
|
<GPUSupport serverId={serverId} />
|
||||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
</div>
|
||||||
<GPUSupport serverId={serverId} />
|
</TabsContent>
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import {
|
import { KeyIcon, Loader2, MoreHorizontal, ServerIcon } from "lucide-react";
|
||||||
Clock,
|
|
||||||
Key,
|
|
||||||
KeyIcon,
|
|
||||||
Loader2,
|
|
||||||
MoreHorizontal,
|
|
||||||
Network,
|
|
||||||
ServerIcon,
|
|
||||||
Terminal,
|
|
||||||
Trash2,
|
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
@@ -29,15 +18,20 @@ import {
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Table,
|
||||||
TooltipContent,
|
TableBody,
|
||||||
TooltipProvider,
|
TableCaption,
|
||||||
TooltipTrigger,
|
TableCell,
|
||||||
} from "@/components/ui/tooltip";
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
|
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
|
||||||
import { TerminalModal } from "../web-server/terminal-modal";
|
import { TerminalModal } from "../web-server/terminal-modal";
|
||||||
@@ -65,7 +59,7 @@ export const ShowServers = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{query?.success && isCloud && <WelcomeSuscription />}
|
{query?.success && isCloud && <WelcomeSuscription />}
|
||||||
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="">
|
<CardHeader className="">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
@@ -120,44 +114,185 @@ export const ShowServers = () => {
|
|||||||
<HandleServers />
|
<HandleServers />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<Table>
|
||||||
{data?.map((server) => {
|
<TableCaption>
|
||||||
const canDelete = server.totalSum === 0;
|
<div className="flex flex-col gap-4">
|
||||||
const isActive = server.serverStatus === "active";
|
See all servers
|
||||||
const isBuildServer = server.serverType === "build";
|
</div>
|
||||||
return (
|
</TableCaption>
|
||||||
<Card
|
<TableHeader>
|
||||||
key={server.serverId}
|
<TableRow>
|
||||||
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
<TableHead className="text-left">Name</TableHead>
|
||||||
>
|
{isCloud && (
|
||||||
<CardHeader className="pb-3">
|
<TableHead className="text-center">
|
||||||
<div className="flex items-start justify-between">
|
Status
|
||||||
<div className="flex items-center gap-2">
|
</TableHead>
|
||||||
<ServerIcon className="size-5 text-muted-foreground" />
|
)}
|
||||||
<CardTitle className="text-lg">
|
<TableHead className="text-center">
|
||||||
{server.name}
|
IP Address
|
||||||
</CardTitle>
|
</TableHead>
|
||||||
</div>
|
<TableHead className="text-center">
|
||||||
{isActive &&
|
Port
|
||||||
server.sshKeyId &&
|
</TableHead>
|
||||||
!isBuildServer && (
|
<TableHead className="text-center">
|
||||||
<DropdownMenu>
|
Username
|
||||||
<DropdownMenuTrigger asChild>
|
</TableHead>
|
||||||
<Button
|
<TableHead className="text-center">
|
||||||
variant="ghost"
|
SSH Key
|
||||||
className="h-8 w-8 p-0"
|
</TableHead>
|
||||||
>
|
<TableHead className="text-center">
|
||||||
<span className="sr-only">
|
Created
|
||||||
More options
|
</TableHead>
|
||||||
</span>
|
<TableHead className="text-right">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
Actions
|
||||||
</Button>
|
</TableHead>
|
||||||
</DropdownMenuTrigger>
|
</TableRow>
|
||||||
<DropdownMenuContent align="end">
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.map((server) => {
|
||||||
|
const canDelete = server.totalSum === 0;
|
||||||
|
const isActive = server.serverStatus === "active";
|
||||||
|
return (
|
||||||
|
<TableRow key={server.serverId}>
|
||||||
|
<TableCell className="text-left">
|
||||||
|
{server.name}
|
||||||
|
</TableCell>
|
||||||
|
{isCloud && (
|
||||||
|
<TableHead className="text-center">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
server.serverStatus === "active"
|
||||||
|
? "default"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{server.serverStatus}
|
||||||
|
</Badge>
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge>{server.ipAddress}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{server.port}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{server.username}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{server.sshKeyId ? "Yes" : "No"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{format(
|
||||||
|
new Date(server.createdAt),
|
||||||
|
"PPpp",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-right flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
Open menu
|
||||||
|
</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
Actions
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<>
|
||||||
|
{server.sshKeyId && (
|
||||||
|
<TerminalModal
|
||||||
|
serverId={server.serverId}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"settings.common.enterTerminal",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TerminalModal>
|
||||||
|
)}
|
||||||
|
<SetupServer
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HandleServers
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{server.sshKeyId && (
|
||||||
|
<ShowServerActions
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogAction
|
||||||
|
disabled={!canDelete}
|
||||||
|
title={
|
||||||
|
canDelete
|
||||||
|
? "Delete Server"
|
||||||
|
: "Server has active services"
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
canDelete ? (
|
||||||
|
"This will delete the server and all associated data"
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
You can not delete this server
|
||||||
|
because it has active services.
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
You have active services
|
||||||
|
associated with this server,
|
||||||
|
please delete them first.
|
||||||
|
</AlertBlock>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
serverId: server.serverId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
`Server ${server.name} deleted successfully`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Delete Server
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogAction>
|
||||||
|
|
||||||
|
{isActive && server.sshKeyId && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
Advanced
|
Extra
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<ShowTraefikFileSystemModal
|
<ShowTraefikFileSystemModal
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
/>
|
||||||
@@ -173,267 +308,29 @@ export const ShowServers = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ShowSwarmOverviewModal
|
<ShowSwarmOverviewModal
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
/>
|
||||||
<ShowNodesModal
|
<ShowNodesModal
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShowSchedulesModal
|
<ShowSchedulesModal
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
/>
|
||||||
</DropdownMenuContent>
|
</>
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<TooltipProvider>
|
|
||||||
<div className="flex gap-2 mt-2 flex-wrap">
|
|
||||||
{isCloud && (
|
|
||||||
<>
|
|
||||||
{server.serverStatus === "active" ? (
|
|
||||||
<Badge variant="default">
|
|
||||||
{server.serverStatus}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Tooltip delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="inline-block">
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
className="cursor-help"
|
|
||||||
>
|
|
||||||
{server.serverStatus}
|
|
||||||
</Badge>
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
className="max-w-xs"
|
|
||||||
side="bottom"
|
|
||||||
>
|
|
||||||
<p className="text-sm">
|
|
||||||
This server is deactivated due
|
|
||||||
to lack of payment. Please pay
|
|
||||||
your invoice to reactivate it.
|
|
||||||
If you think this is an error,
|
|
||||||
please contact support.
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
isBuildServer
|
|
||||||
? "secondary"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{server.serverType}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 flex-1 flex flex-col">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Network className="size-4 text-muted-foreground" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
IP:
|
|
||||||
</span>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{server.ipAddress}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Port:
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{server.port}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<User className="size-4 text-muted-foreground" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
User:
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{server.username}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<Key className="size-4 text-muted-foreground" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
SSH Key:
|
|
||||||
</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{server.sshKeyId ? "Yes" : "No"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm pt-2 border-t">
|
|
||||||
<Clock className="size-4 text-muted-foreground" />
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Created{" "}
|
|
||||||
{format(
|
|
||||||
new Date(server.createdAt),
|
|
||||||
"PPp",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Compact Actions */}
|
|
||||||
{isActive && (
|
|
||||||
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
|
|
||||||
<div className="flex items-center gap-2 w-full">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<SetupServer
|
|
||||||
serverId={server.serverId}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
className="max-w-xs"
|
|
||||||
side="bottom"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="font-semibold">
|
|
||||||
Setup Server
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Configure and initialize your
|
|
||||||
server with Docker, Traefik, and
|
|
||||||
other essential services
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TooltipProvider>
|
|
||||||
{server.sshKeyId && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<TerminalModal
|
|
||||||
serverId={server.serverId}
|
|
||||||
asButton={true}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9"
|
|
||||||
>
|
|
||||||
<Terminal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TerminalModal>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Terminal</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
<Tooltip>
|
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<HandleServers
|
|
||||||
serverId={server.serverId}
|
|
||||||
asButton={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Edit Server</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{server.sshKeyId && !isBuildServer && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<ShowServerActions
|
|
||||||
serverId={server.serverId}
|
|
||||||
asButton={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Web Server Actions</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<DialogAction
|
|
||||||
disabled={!canDelete}
|
|
||||||
title={
|
|
||||||
canDelete
|
|
||||||
? "Delete Server"
|
|
||||||
: "Server has active services"
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
canDelete ? (
|
|
||||||
"This will delete the server and all associated data"
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
You can not delete this
|
|
||||||
server because it has
|
|
||||||
active services.
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
You have active services
|
|
||||||
associated with this
|
|
||||||
server, please delete
|
|
||||||
them first.
|
|
||||||
</AlertBlock>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
serverId: server.serverId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
refetch();
|
|
||||||
toast.success(
|
|
||||||
`Server ${server.name} deleted successfully`,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>
|
|
||||||
{canDelete
|
|
||||||
? "Delete Server"
|
|
||||||
: "Cannot delete - has active services"}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mt-4">
|
|
||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<HandleServers />
|
<HandleServers />
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
enabled: !!serverId,
|
enabled: !!serverId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { data: server } = api.server.one.useQuery(
|
|
||||||
{ serverId },
|
|
||||||
{
|
|
||||||
enabled: !!serverId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const isBuildServer = server?.serverType === "build";
|
|
||||||
const _utils = api.useUtils();
|
const _utils = api.useUtils();
|
||||||
return (
|
return (
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
@@ -80,9 +73,7 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
<div className="border rounded-lg p-4">
|
<div className="border rounded-lg p-4">
|
||||||
<h3 className="text-lg font-semibold mb-1">Status</h3>
|
<h3 className="text-lg font-semibold mb-1">Status</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
{isBuildServer
|
Shows the server configuration status
|
||||||
? "Shows the build server configuration status"
|
|
||||||
: "Shows the server configuration status"}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2.5">
|
<div className="grid gap-2.5">
|
||||||
<StatusRow
|
<StatusRow
|
||||||
@@ -94,17 +85,15 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!isBuildServer && (
|
<StatusRow
|
||||||
<StatusRow
|
label="RClone Installed"
|
||||||
label="RClone Installed"
|
isEnabled={data?.rclone?.enabled}
|
||||||
isEnabled={data?.rclone?.enabled}
|
description={
|
||||||
description={
|
data?.rclone?.enabled
|
||||||
data?.rclone?.enabled
|
? `Installed: ${data?.rclone?.version}`
|
||||||
? `Installed: ${data?.rclone?.version}`
|
: undefined
|
||||||
: undefined
|
}
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<StatusRow
|
<StatusRow
|
||||||
label="Nixpacks Installed"
|
label="Nixpacks Installed"
|
||||||
isEnabled={data?.nixpacks?.enabled}
|
isEnabled={data?.nixpacks?.enabled}
|
||||||
@@ -124,36 +113,23 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StatusRow
|
<StatusRow
|
||||||
label="Railpack Installed"
|
label="Docker Swarm Initialized"
|
||||||
isEnabled={data?.railpack?.enabled}
|
isEnabled={data?.isSwarmInstalled}
|
||||||
description={
|
description={
|
||||||
data?.railpack?.enabled
|
data?.isSwarmInstalled
|
||||||
? `Installed: ${data?.railpack?.version}`
|
? "Initialized"
|
||||||
: undefined
|
: "Not Initialized"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Dokploy Network Created"
|
||||||
|
isEnabled={data?.isDokployNetworkInstalled}
|
||||||
|
description={
|
||||||
|
data?.isDokployNetworkInstalled
|
||||||
|
? "Created"
|
||||||
|
: "Not Created"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!isBuildServer && (
|
|
||||||
<>
|
|
||||||
<StatusRow
|
|
||||||
label="Docker Swarm Initialized"
|
|
||||||
isEnabled={data?.isSwarmInstalled}
|
|
||||||
description={
|
|
||||||
data?.isSwarmInstalled
|
|
||||||
? "Initialized"
|
|
||||||
: "Not Initialized"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusRow
|
|
||||||
label="Dokploy Network Created"
|
|
||||||
isEnabled={data?.isDokployNetworkInstalled}
|
|
||||||
description={
|
|
||||||
data?.isDokployNetworkInstalled
|
|
||||||
? "Created"
|
|
||||||
: "Not Created"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<StatusRow
|
<StatusRow
|
||||||
label="Main Directory Created"
|
label="Main Directory Created"
|
||||||
isEnabled={data?.isMainDirectoryInstalled}
|
isEnabled={data?.isMainDirectoryInstalled}
|
||||||
@@ -163,6 +139,15 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
: "Not Created"
|
: "Not Created"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<StatusRow
|
||||||
|
label="Railpack Installed"
|
||||||
|
isEnabled={data?.railpack?.enabled}
|
||||||
|
description={
|
||||||
|
data?.railpack?.enabled
|
||||||
|
? `Installed: ${data?.railpack?.version}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,7 +95,6 @@ export const CreateServer = ({ stepper }: Props) => {
|
|||||||
port: data.port || 22,
|
port: data.port || 22,
|
||||||
username: data.username || "root",
|
username: data.username || "root",
|
||||||
sshKeyId: data.sshKeyId || "",
|
sshKeyId: data.sshKeyId || "",
|
||||||
serverType: "deploy",
|
|
||||||
})
|
})
|
||||||
.then(async (_data) => {
|
.then(async (_data) => {
|
||||||
toast.success("Server Created");
|
toast.success("Server Created");
|
||||||
|
|||||||
@@ -158,7 +158,6 @@ export const AddInvitation = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="member">Member</SelectItem>
|
<SelectItem value="member">Member</SelectItem>
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
|
|
||||||
const changeRoleSchema = z.object({
|
|
||||||
role: z.enum(["admin", "member"]),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
memberId: string;
|
|
||||||
currentRole: "admin" | "member";
|
|
||||||
userEmail: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const { mutateAsync, isError, error, isLoading } =
|
|
||||||
api.organization.updateMemberRole.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<ChangeRoleSchema>({
|
|
||||||
defaultValues: {
|
|
||||||
role: currentRole,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(changeRoleSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
form.reset({
|
|
||||||
role: currentRole,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [form, currentRole, isOpen]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: ChangeRoleSchema) => {
|
|
||||||
await mutateAsync({
|
|
||||||
memberId,
|
|
||||||
role: data.role,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Role updated successfully");
|
|
||||||
await utils.user.all.invalidate();
|
|
||||||
setIsOpen(false);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
toast.error(error?.message || "Error updating role");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger className="" asChild>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="w-full cursor-pointer"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
Change Role
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-[85vh] sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Change User Role</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Change the role for <strong>{userEmail}</strong>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="hook-form-change-role"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="w-full space-y-4"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="role"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Role</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a role" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="admin">Admin</SelectItem>
|
|
||||||
<SelectItem value="member">Member</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormDescription>
|
|
||||||
<strong>Admin:</strong> Can manage users and settings.
|
|
||||||
<br />
|
|
||||||
<strong>Member:</strong> Limited permissions, can be
|
|
||||||
customized.
|
|
||||||
<br />
|
|
||||||
<em className="text-muted-foreground text-xs">
|
|
||||||
Note: Owner role is intransferible.
|
|
||||||
</em>
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={isLoading}
|
|
||||||
form="hook-form-change-role"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Update Role
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
@@ -29,15 +30,12 @@ import {
|
|||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AddUserPermissions } from "./add-permissions";
|
import { AddUserPermissions } from "./add-permissions";
|
||||||
import { ChangeRole } from "./change-role";
|
|
||||||
|
|
||||||
export const ShowUsers = () => {
|
export const ShowUsers = () => {
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data, isLoading, refetch } = api.user.all.useQuery();
|
const { data, isLoading, refetch } = api.user.all.useQuery();
|
||||||
const { mutateAsync } = api.user.remove.useMutation();
|
const { mutateAsync } = api.user.remove.useMutation();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data: session } = authClient.useSession();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -70,6 +68,7 @@ export const ShowUsers = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
<Table>
|
<Table>
|
||||||
|
<TableCaption>See all users</TableCaption>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[100px]">Email</TableHead>
|
<TableHead className="w-[100px]">Email</TableHead>
|
||||||
@@ -84,52 +83,6 @@ export const ShowUsers = () => {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.map((member) => {
|
{data?.map((member) => {
|
||||||
const currentUserRole = data?.find(
|
|
||||||
(m) => m.user.id === session?.user?.id,
|
|
||||||
)?.role;
|
|
||||||
|
|
||||||
// Owner never has "Edit Permissions" (they're absolute owner)
|
|
||||||
// Other users can edit permissions if target is not themselves and target is a member
|
|
||||||
const canEditPermissions =
|
|
||||||
member.role !== "owner" &&
|
|
||||||
member.role === "member" &&
|
|
||||||
member.user.id !== session?.user?.id;
|
|
||||||
|
|
||||||
// Can change role based on hierarchy:
|
|
||||||
// - Owner: Can change anyone's role (except themselves and other owners)
|
|
||||||
// - Admin: Can only change member roles (not other admins or owners)
|
|
||||||
// - Owner role is intransferible
|
|
||||||
const canChangeRole =
|
|
||||||
member.role !== "owner" &&
|
|
||||||
member.user.id !== session?.user?.id &&
|
|
||||||
(currentUserRole === "owner" ||
|
|
||||||
(currentUserRole === "admin" &&
|
|
||||||
member.role === "member"));
|
|
||||||
|
|
||||||
// Delete/Unlink follow same hierarchy as role changes
|
|
||||||
// - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted)
|
|
||||||
// - Admin: Can only delete/unlink members (not other admins or owner)
|
|
||||||
const canDelete =
|
|
||||||
member.role !== "owner" &&
|
|
||||||
!isCloud &&
|
|
||||||
member.user.id !== session?.user?.id &&
|
|
||||||
(currentUserRole === "owner" ||
|
|
||||||
(currentUserRole === "admin" &&
|
|
||||||
member.role === "member"));
|
|
||||||
|
|
||||||
const canUnlink =
|
|
||||||
member.role !== "owner" &&
|
|
||||||
member.user.id !== session?.user?.id &&
|
|
||||||
(currentUserRole === "owner" ||
|
|
||||||
(currentUserRole === "admin" &&
|
|
||||||
member.role === "member"));
|
|
||||||
|
|
||||||
const hasAnyAction =
|
|
||||||
canEditPermissions ||
|
|
||||||
canChangeRole ||
|
|
||||||
canDelete ||
|
|
||||||
canUnlink;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={member.id}>
|
<TableRow key={member.id}>
|
||||||
<TableCell className="w-[100px]">
|
<TableCell className="w-[100px]">
|
||||||
@@ -158,73 +111,62 @@ export const ShowUsers = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="text-right flex justify-end">
|
<TableCell className="text-right flex justify-end">
|
||||||
{hasAnyAction ? (
|
<DropdownMenu>
|
||||||
<DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
className="h-8 w-8 p-0"
|
||||||
className="h-8 w-8 p-0"
|
>
|
||||||
>
|
<span className="sr-only">Open menu</span>
|
||||||
<span className="sr-only">
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
Open menu
|
</Button>
|
||||||
</span>
|
</DropdownMenuTrigger>
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<DropdownMenuContent align="end">
|
||||||
</Button>
|
<DropdownMenuLabel>
|
||||||
</DropdownMenuTrigger>
|
Actions
|
||||||
<DropdownMenuContent align="end">
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuLabel>
|
|
||||||
Actions
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
{canChangeRole && (
|
{member.role !== "owner" && (
|
||||||
<ChangeRole
|
<AddUserPermissions
|
||||||
memberId={member.id}
|
userId={member.user.id}
|
||||||
currentRole={
|
/>
|
||||||
member.role as "admin" | "member"
|
)}
|
||||||
}
|
|
||||||
userEmail={member.user.email}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canEditPermissions && (
|
{member.role !== "owner" && (
|
||||||
<AddUserPermissions
|
<>
|
||||||
userId={member.user.id}
|
{!isCloud && (
|
||||||
/>
|
<DialogAction
|
||||||
)}
|
title="Delete User"
|
||||||
|
description="Are you sure you want to delete this user?"
|
||||||
{canDelete && (
|
type="destructive"
|
||||||
<DialogAction
|
onClick={async () => {
|
||||||
title="Delete User"
|
await mutateAsync({
|
||||||
description="Are you sure you want to delete this user?"
|
userId: member.user.id,
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
userId: member.user.id,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success(
|
|
||||||
"User deleted successfully",
|
|
||||||
);
|
|
||||||
refetch();
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.then(() => {
|
||||||
toast.error(
|
toast.success(
|
||||||
err?.message ||
|
"User deleted successfully",
|
||||||
"Error deleting user",
|
);
|
||||||
);
|
refetch();
|
||||||
});
|
})
|
||||||
}}
|
.catch(() => {
|
||||||
>
|
toast.error(
|
||||||
<DropdownMenuItem
|
"Error deleting destination",
|
||||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
);
|
||||||
onSelect={(e) => e.preventDefault()}
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Delete User
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||||
</DialogAction>
|
onSelect={(e) =>
|
||||||
)}
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
|
||||||
{canUnlink && (
|
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Unlink User"
|
title="Unlink User"
|
||||||
description="Are you sure you want to unlink this user?"
|
description="Are you sure you want to unlink this user?"
|
||||||
@@ -238,6 +180,8 @@ export const ShowUsers = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(orgCount);
|
||||||
|
|
||||||
if (orgCount === 1) {
|
if (orgCount === 1) {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
userId: member.user.id,
|
userId: member.user.id,
|
||||||
@@ -283,21 +227,10 @@ export const ShowUsers = () => {
|
|||||||
Unlink User
|
Unlink User
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
</>
|
||||||
</DropdownMenuContent>
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenuContent>
|
||||||
) : (
|
</DropdownMenu>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
|
||||||
No actions available
|
|
||||||
</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
const addServerDomain = z
|
const addServerDomain = z
|
||||||
.object({
|
.object({
|
||||||
domain: z.string().trim().toLowerCase(),
|
domain: z.string(),
|
||||||
letsEncryptEmail: z.string(),
|
letsEncryptEmail: z.string(),
|
||||||
https: z.boolean().optional(),
|
https: z.boolean().optional(),
|
||||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||||
@@ -49,11 +49,7 @@ const addServerDomain = z
|
|||||||
message: "Required",
|
message: "Required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
|
||||||
data.https &&
|
|
||||||
data.certificateType === "letsencrypt" &&
|
|
||||||
!data.letsEncryptEmail
|
|
||||||
) {
|
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message:
|
message:
|
||||||
@@ -67,7 +63,7 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
|
|||||||
|
|
||||||
export const WebDomain = () => {
|
export const WebDomain = () => {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
const { data, refetch } = api.user.get.useQuery();
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isLoading } =
|
||||||
api.settings.assignDomainServer.useMutation();
|
api.settings.assignDomainServer.useMutation();
|
||||||
|
|
||||||
@@ -82,15 +78,15 @@ export const WebDomain = () => {
|
|||||||
});
|
});
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domain = form.watch("domain") || "";
|
const domain = form.watch("domain") || "";
|
||||||
const host = data?.host || "";
|
const host = data?.user?.host || "";
|
||||||
const hasChanged = domain !== host;
|
const hasChanged = domain !== host;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
domain: data?.host || "",
|
domain: data?.user?.host || "",
|
||||||
certificateType: data?.certificateType || "none",
|
certificateType: data?.user?.certificateType,
|
||||||
letsEncryptEmail: data?.letsEncryptEmail || "",
|
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
|
||||||
https: data?.https || false,
|
https: data?.user?.https || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import { UpdateServer } from "./web-server/update-server";
|
|||||||
|
|
||||||
export const WebServer = () => {
|
export const WebServer = () => {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const { data: webServerSettings } =
|
const { data } = api.user.get.useQuery();
|
||||||
api.settings.getWebServerSettings.useQuery();
|
|
||||||
|
|
||||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ export const WebServer = () => {
|
|||||||
|
|
||||||
<div className="flex items-center flex-wrap justify-between gap-4">
|
<div className="flex items-center flex-wrap justify-between gap-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Server IP: {webServerSettings?.serverIp}
|
Server IP: {data?.user.serverIp}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Version: {dokployVersion}
|
Version: {dokployVersion}
|
||||||
|
|||||||
@@ -105,9 +105,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error) {
|
} catch {}
|
||||||
toast.error((error as Error).message || "Error updating Traefik ports");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -158,11 +156,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="pr-4">
|
<ScrollArea className="h-[400px] pr-4">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<Card key={field.id} className="bg-transparent">
|
<Card key={field.id} className="bg-transparent">
|
||||||
<CardContent className="grid grid-cols-4 gap-4 p-4 transparent">
|
<CardContent className="grid grid-cols-[1fr_1fr_1fr_auto] gap-4 p-4 transparent">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`ports.${index}.targetPort`}
|
name={`ports.${index}.targetPort`}
|
||||||
@@ -305,12 +303,6 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
The Traefik container will be recreated from scratch. This
|
|
||||||
means the container will be deleted and created again, which
|
|
||||||
may cause downtime in your applications.
|
|
||||||
</AlertBlock>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -24,16 +24,10 @@ const getTerminalKey = () => {
|
|||||||
interface Props {
|
interface Props {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
asButton?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TerminalModal = ({
|
export const TerminalModal = ({ children, serverId }: Props) => {
|
||||||
children,
|
|
||||||
serverId,
|
|
||||||
asButton = false,
|
|
||||||
}: Props) => {
|
|
||||||
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
|
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const isLocalServer = serverId === "local";
|
const isLocalServer = serverId === "local";
|
||||||
|
|
||||||
const { data } = api.server.one.useQuery(
|
const { data } = api.server.one.useQuery(
|
||||||
@@ -49,20 +43,15 @@ export const TerminalModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog>
|
||||||
{asButton ? (
|
<DialogTrigger asChild>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer space-x-3"
|
className="w-full cursor-pointer space-x-3"
|
||||||
onSelect={(e) => {
|
onSelect={(e) => e.preventDefault()}
|
||||||
e.preventDefault();
|
|
||||||
setIsOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
</DialogTrigger>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="sm:max-w-7xl"
|
className="sm:max-w-7xl"
|
||||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||||
|
|||||||
@@ -46,15 +46,15 @@ interface Props {
|
|||||||
export const UpdateServerIp = ({ children }: Props) => {
|
export const UpdateServerIp = ({ children }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
const { data } = api.user.get.useQuery();
|
||||||
const { data: ip } = api.server.publicIp.useQuery();
|
const { data: ip } = api.server.publicIp.useQuery();
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } =
|
const { mutateAsync, isLoading, error, isError } =
|
||||||
api.settings.updateServerIp.useMutation();
|
api.user.update.useMutation();
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
serverIp: data?.serverIp || "",
|
serverIp: data?.user.serverIp || "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
});
|
});
|
||||||
@@ -62,11 +62,13 @@ export const UpdateServerIp = ({ children }: Props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
serverIp: data.serverIp || "",
|
serverIp: data.user.serverIp || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const setCurrentIp = () => {
|
const setCurrentIp = () => {
|
||||||
if (!ip) return;
|
if (!ip) return;
|
||||||
form.setValue("serverIp", ip);
|
form.setValue("serverIp", ip);
|
||||||
@@ -78,7 +80,7 @@ export const UpdateServerIp = ({ children }: Props) => {
|
|||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Server IP Updated");
|
toast.success("Server IP Updated");
|
||||||
await refetch();
|
await utils.user.get.invalidate();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||||
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
import { ChatwootWidget } from "../shared/ChatwootWidget";
|
||||||
import Page from "./side";
|
import Page from "./side";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,9 +25,7 @@ export const DashboardLayout = ({ children }: Props) => {
|
|||||||
<>
|
<>
|
||||||
<Page>{children}</Page>
|
<Page>{children}</Page>
|
||||||
{isCloud === true && isUserSubscribed === true && (
|
{isCloud === true && isUserSubscribed === true && (
|
||||||
<>
|
<ChatwootWidget websiteToken="USCpQRKzHvFMssf3p6Eacae5" />
|
||||||
<HubSpotWidget />
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{haveRootAccess === true && <ImpersonationBar />}
|
{haveRootAccess === true && <ImpersonationBar />}
|
||||||
|
|||||||
@@ -158,8 +158,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/schedules",
|
url: "/dashboard/schedules",
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
// Only enabled in non-cloud environments
|
// Only enabled in non-cloud environments
|
||||||
isEnabled: ({ isCloud, auth }) =>
|
isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner",
|
||||||
!isCloud && (auth?.role === "owner" || auth?.role === "admin"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -169,9 +168,7 @@ const MENU: Menu = {
|
|||||||
// Only enabled for admins and users with access to Traefik files in non-cloud environments
|
// Only enabled for admins and users with access to Traefik files in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!(
|
!!(
|
||||||
(auth?.role === "owner" ||
|
(auth?.role === "owner" || auth?.canAccessToTraefikFiles) &&
|
||||||
auth?.role === "admin" ||
|
|
||||||
auth?.canAccessToTraefikFiles) &&
|
|
||||||
!isCloud
|
!isCloud
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -182,12 +179,7 @@ const MENU: Menu = {
|
|||||||
icon: BlocksIcon,
|
icon: BlocksIcon,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!(
|
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
||||||
(auth?.role === "owner" ||
|
|
||||||
auth?.role === "admin" ||
|
|
||||||
auth?.canAccessToDocker) &&
|
|
||||||
!isCloud
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -196,12 +188,7 @@ const MENU: Menu = {
|
|||||||
icon: PieChart,
|
icon: PieChart,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!(
|
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
||||||
(auth?.role === "owner" ||
|
|
||||||
auth?.role === "admin" ||
|
|
||||||
auth?.canAccessToDocker) &&
|
|
||||||
!isCloud
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -210,12 +197,7 @@ const MENU: Menu = {
|
|||||||
icon: Forward,
|
icon: Forward,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!(
|
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
||||||
(auth?.role === "owner" ||
|
|
||||||
auth?.role === "admin" ||
|
|
||||||
auth?.canAccessToDocker) &&
|
|
||||||
!isCloud
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Legacy unused menu, adjusted to the new structure
|
// Legacy unused menu, adjusted to the new structure
|
||||||
@@ -282,8 +264,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/server",
|
url: "/dashboard/settings/server",
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
// Only enabled for admins in non-cloud environments
|
// Only enabled for admins in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
||||||
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -297,8 +278,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/servers",
|
url: "/dashboard/settings/servers",
|
||||||
icon: Server,
|
icon: Server,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -306,8 +286,7 @@ const MENU: Menu = {
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
url: "/dashboard/settings/users",
|
url: "/dashboard/settings/users",
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -316,19 +295,14 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/ssh-keys",
|
url: "/dashboard/settings/ssh-keys",
|
||||||
// Only enabled for admins and users with access to SSH keys
|
// Only enabled for admins and users with access to SSH keys
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) =>
|
||||||
!!(
|
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
|
||||||
auth?.role === "owner" ||
|
|
||||||
auth?.canAccessToSSHKeys ||
|
|
||||||
auth?.role === "admin"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "AI",
|
title: "AI",
|
||||||
icon: BotIcon,
|
icon: BotIcon,
|
||||||
url: "/dashboard/settings/ai",
|
url: "/dashboard/settings/ai",
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -337,11 +311,7 @@ const MENU: Menu = {
|
|||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
// Only enabled for admins and users with access to Git providers
|
// Only enabled for admins and users with access to Git providers
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) =>
|
||||||
!!(
|
!!(auth?.role === "owner" || auth?.canAccessToGitProviders),
|
||||||
auth?.role === "owner" ||
|
|
||||||
auth?.canAccessToGitProviders ||
|
|
||||||
auth?.role === "admin"
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -349,8 +319,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/registry",
|
url: "/dashboard/settings/registry",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -358,8 +327,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/destinations",
|
url: "/dashboard/settings/destinations",
|
||||||
icon: Database,
|
icon: Database,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -368,8 +336,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/certificates",
|
url: "/dashboard/settings/certificates",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -377,8 +344,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/cluster",
|
url: "/dashboard/settings/cluster",
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
// Only enabled for admins in non-cloud environments
|
// Only enabled for admins in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
||||||
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -386,8 +352,7 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/notifications",
|
url: "/dashboard/settings/notifications",
|
||||||
icon: Bell,
|
icon: Bell,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -753,9 +718,7 @@ function SidebarLogo() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(user?.role === "owner" ||
|
{(user?.role === "owner" || isCloud) && (
|
||||||
user?.role === "admin" ||
|
|
||||||
isCloud) && (
|
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<AddOrganization />
|
<AddOrganization />
|
||||||
@@ -1119,7 +1082,7 @@ export default function Page({ children }: Props) {
|
|||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarMenu className="flex flex-col gap-2">
|
<SidebarMenu className="flex flex-col gap-2">
|
||||||
{!isCloud && (auth?.role === "owner" || auth?.role === "admin") && (
|
{!isCloud && auth?.role === "owner" && (
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<UpdateServerButton />
|
<UpdateServerButton />
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -49,9 +49,7 @@ export const UserNav = () => {
|
|||||||
alt={data?.user?.image || ""}
|
alt={data?.user?.image || ""}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback className="rounded-lg">
|
<AvatarFallback className="rounded-lg">
|
||||||
{getFallbackAvatarInitials(
|
{getFallbackAvatarInitials(data?.user?.name)}
|
||||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
|
|
||||||
)}
|
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
@@ -104,9 +102,7 @@ export const UserNav = () => {
|
|||||||
>
|
>
|
||||||
Monitoring
|
Monitoring
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{(data?.role === "owner" ||
|
{(data?.role === "owner" || data?.canAccessToTraefikFiles) && (
|
||||||
data?.role === "admin" ||
|
|
||||||
data?.canAccessToTraefikFiles) && (
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -116,9 +112,7 @@ export const UserNav = () => {
|
|||||||
Traefik
|
Traefik
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{(data?.role === "owner" ||
|
{(data?.role === "owner" || data?.canAccessToDocker) && (
|
||||||
data?.role === "admin" ||
|
|
||||||
data?.canAccessToDocker) && (
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -132,7 +126,7 @@ export const UserNav = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
(data?.role === "owner" || data?.role === "admin") && (
|
data?.role === "owner" && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import Script from "next/script";
|
|
||||||
|
|
||||||
export const HubSpotWidget = () => {
|
|
||||||
return (
|
|
||||||
<Script
|
|
||||||
id="hs-script-loader"
|
|
||||||
type="text/javascript"
|
|
||||||
src="//js-eu1.hs-scripts.com/147033433.js"
|
|
||||||
strategy="lazyOnload"
|
|
||||||
async
|
|
||||||
defer
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -6,29 +5,16 @@ import {
|
|||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
BreadcrumbLink,
|
BreadcrumbLink,
|
||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
|
|
||||||
interface BreadcrumbEntry {
|
|
||||||
name: string;
|
|
||||||
href?: string;
|
|
||||||
dropdownItems?: {
|
|
||||||
name: string;
|
|
||||||
href: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
list: BreadcrumbEntry[];
|
list: {
|
||||||
|
name: string;
|
||||||
|
href?: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BreadcrumbSidebar = ({ list }: Props) => {
|
export const BreadcrumbSidebar = ({ list }: Props) => {
|
||||||
@@ -43,29 +29,13 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
|
|||||||
{list.map((item, index) => (
|
{list.map((item, index) => (
|
||||||
<Fragment key={item.name}>
|
<Fragment key={item.name}>
|
||||||
<BreadcrumbItem className="block">
|
<BreadcrumbItem className="block">
|
||||||
{item.dropdownItems && item.dropdownItems.length > 0 ? (
|
<BreadcrumbLink href={item?.href} asChild={!!item?.href}>
|
||||||
<DropdownMenu>
|
{item.href ? (
|
||||||
<DropdownMenuTrigger className="flex items-center gap-1 hover:text-foreground transition-colors outline-none">
|
<Link href={item?.href}>{item?.name}</Link>
|
||||||
{item.name}
|
) : (
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
item?.name
|
||||||
</DropdownMenuTrigger>
|
)}
|
||||||
<DropdownMenuContent align="start">
|
</BreadcrumbLink>
|
||||||
{item.dropdownItems.map((subItem) => (
|
|
||||||
<DropdownMenuItem key={subItem.href} asChild>
|
|
||||||
<Link href={subItem.href}>{subItem.name}</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : (
|
|
||||||
<BreadcrumbLink href={item?.href} asChild={!!item?.href}>
|
|
||||||
{item.href ? (
|
|
||||||
<Link href={item?.href}>{item?.name}</Link>
|
|
||||||
) : (
|
|
||||||
<BreadcrumbPage>{item?.name}</BreadcrumbPage>
|
|
||||||
)}
|
|
||||||
</BreadcrumbLink>
|
|
||||||
)}
|
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
{index + 1 < list.length && (
|
{index + 1 < list.length && (
|
||||||
<BreadcrumbSeparator className="block" />
|
<BreadcrumbSeparator className="block" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center space-x-2">
|
<div className="flex w-full items-center space-x-2">
|
||||||
<Input ref={inputRef} {...props} type="password" />
|
<Input ref={inputRef} type={"password"} {...props} />
|
||||||
<Button
|
<Button
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { isSolidColorAvatar } from "@/lib/avatar-utils";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Avatar = React.forwardRef<
|
const Avatar = React.forwardRef<
|
||||||
@@ -20,33 +20,14 @@ Avatar.displayName = AvatarPrimitive.Root.displayName;
|
|||||||
|
|
||||||
const AvatarImage = React.forwardRef<
|
const AvatarImage = React.forwardRef<
|
||||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & {
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
src?: string | null;
|
>(({ className, ...props }, ref) => (
|
||||||
}
|
<AvatarPrimitive.Image
|
||||||
>(({ className, src, ...props }, ref) => {
|
ref={ref}
|
||||||
if (isSolidColorAvatar(src)) {
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
return (
|
{...props}
|
||||||
<div
|
/>
|
||||||
key={`solid-${src}`}
|
));
|
||||||
ref={ref}
|
|
||||||
className={cn("aspect-square h-full w-full rounded-full", className)}
|
|
||||||
style={{
|
|
||||||
backgroundColor: src,
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AvatarPrimitive.Image
|
|
||||||
ref={ref}
|
|
||||||
className={cn("aspect-square h-full w-full", className)}
|
|
||||||
src={src ?? ""}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
const AvatarFallback = React.forwardRef<
|
const AvatarFallback = React.forwardRef<
|
||||||
|
|||||||
@@ -1,75 +1,18 @@
|
|||||||
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
|
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { generateRandomPassword } from "@/lib/password-utils";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
enablePasswordGenerator?: boolean;
|
|
||||||
passwordGeneratorLength?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
(
|
({ className, errorMessage, type, ...props }, ref) => {
|
||||||
{
|
|
||||||
className,
|
|
||||||
errorMessage,
|
|
||||||
type,
|
|
||||||
enablePasswordGenerator = false,
|
|
||||||
passwordGeneratorLength,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref,
|
|
||||||
) => {
|
|
||||||
const [showPassword, setShowPassword] = React.useState(false);
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
||||||
const isPassword = type === "password";
|
const isPassword = type === "password";
|
||||||
const shouldShowGenerator =
|
|
||||||
isPassword &&
|
|
||||||
enablePasswordGenerator !== false &&
|
|
||||||
!props.disabled &&
|
|
||||||
!props.readOnly;
|
|
||||||
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
|
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
|
||||||
|
|
||||||
const setRefs = React.useCallback(
|
|
||||||
(node: HTMLInputElement | null) => {
|
|
||||||
// @ts-ignore
|
|
||||||
inputRef.current = node;
|
|
||||||
if (typeof ref === "function") {
|
|
||||||
ref(node);
|
|
||||||
} else if (ref) {
|
|
||||||
ref.current = node;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[ref],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleGeneratePassword = () => {
|
|
||||||
const nextValue =
|
|
||||||
typeof passwordGeneratorLength === "number" &&
|
|
||||||
passwordGeneratorLength > 0
|
|
||||||
? generateRandomPassword(Math.floor(passwordGeneratorLength))
|
|
||||||
: generateRandomPassword();
|
|
||||||
|
|
||||||
const input = inputRef.current;
|
|
||||||
if (!input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
|
||||||
HTMLInputElement.prototype,
|
|
||||||
"value",
|
|
||||||
)?.set;
|
|
||||||
if (valueSetter) {
|
|
||||||
valueSetter.call(input, nextValue);
|
|
||||||
} else {
|
|
||||||
input.value = nextValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
@@ -78,39 +21,25 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
className={cn(
|
className={cn(
|
||||||
// bg-gray
|
// bg-gray
|
||||||
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
|
isPassword && "pr-10", // Add padding for the eye icon
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={setRefs}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{isPassword && (
|
{isPassword && (
|
||||||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
|
<button
|
||||||
{shouldShowGenerator && (
|
type="button"
|
||||||
<button
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||||
type="button"
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="hover:text-foreground focus:outline-none"
|
tabIndex={-1}
|
||||||
onClick={handleGeneratePassword}
|
>
|
||||||
aria-label="Generate password"
|
{showPassword ? (
|
||||||
title="Generate password"
|
<EyeOffIcon className="h-4 w-4" />
|
||||||
tabIndex={-1}
|
) : (
|
||||||
>
|
<EyeIcon className="h-4 w-4" />
|
||||||
<RefreshCcw className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
|
||||||
className="hover:text-foreground focus:outline-none"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOffIcon className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<EyeIcon className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import { MinusIcon, PlusIcon } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
|
|
||||||
export interface UnitConverter {
|
|
||||||
toValue: (raw: string | undefined) => number;
|
|
||||||
fromValue: (value: number) => string;
|
|
||||||
formatDisplay: (value: number) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createConverter = (
|
|
||||||
multiplier: number,
|
|
||||||
formatDisplay: (value: number) => string,
|
|
||||||
): UnitConverter => ({
|
|
||||||
toValue: (raw) => {
|
|
||||||
if (!raw) return 0;
|
|
||||||
const value = Number.parseInt(raw, 10);
|
|
||||||
return Number.isNaN(value) ? 0 : value / multiplier;
|
|
||||||
},
|
|
||||||
fromValue: (value) =>
|
|
||||||
value <= 0 ? "" : String(Math.round(value * multiplier)),
|
|
||||||
formatDisplay,
|
|
||||||
});
|
|
||||||
|
|
||||||
interface NumberInputWithStepsProps {
|
|
||||||
value: string | undefined;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder: string;
|
|
||||||
step: number;
|
|
||||||
converter: UnitConverter;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NumberInputWithSteps = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
step,
|
|
||||||
converter,
|
|
||||||
}: NumberInputWithStepsProps) => {
|
|
||||||
const numericValue = converter.toValue(value);
|
|
||||||
const displayValue = converter.formatDisplay(numericValue);
|
|
||||||
|
|
||||||
const handleIncrement = () =>
|
|
||||||
onChange(converter.fromValue(numericValue + step));
|
|
||||||
const handleDecrement = () =>
|
|
||||||
onChange(converter.fromValue(Math.max(0, numericValue - step)));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 shrink-0"
|
|
||||||
onClick={handleDecrement}
|
|
||||||
disabled={numericValue <= 0}
|
|
||||||
>
|
|
||||||
<MinusIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Input
|
|
||||||
placeholder={placeholder}
|
|
||||||
value={value || ""}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="text-center"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 shrink-0"
|
|
||||||
onClick={handleIncrement}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{displayValue && (
|
|
||||||
<span className="text-xs text-muted-foreground text-center">
|
|
||||||
{displayValue}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -44,20 +44,14 @@ export function TimeBadge() {
|
|||||||
.padStart(2, "0")}`;
|
.padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formattedTime = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone: serverTime.timezone,
|
|
||||||
timeStyle: "medium",
|
|
||||||
hour12: false,
|
|
||||||
}).format(time);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center rounded-full border p-1 text-xs whitespace-nowrap max-w-full overflow-hidden gap-1">
|
<div className="inline-flex items-center gap-2 rounded-md border px-2 py-1 text-xs sm:text-sm whitespace-nowrap max-w-full overflow-hidden">
|
||||||
<div className="inline-flex items-center px-1 gap-1">
|
<span className="hidden sm:inline">Server Time:</span>
|
||||||
<span className="hidden sm:inline">Server Time:</span>
|
<span className="font-medium tabular-nums">
|
||||||
<span className="font-medium tabular-nums">{formattedTime}</span>
|
{time.toLocaleTimeString()}
|
||||||
</div>
|
</span>
|
||||||
<span className="hidden sm:inline text-primary/70 border rounded-full bg-foreground/5 px-1.5 py-0.5">
|
<span className="hidden sm:inline text-muted-foreground">
|
||||||
{serverTime.timezone} | {getUtcOffset(serverTime.timezone)}
|
({serverTime.timezone} | {getUtcOffset(serverTime.timezone)})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
-- Fix inconsistent date formats in environment.createdAt field
|
|
||||||
-- Convert PostgreSQL timestamp format to ISO 8601 format
|
|
||||||
-- This addresses issue #2992 where old environments have PostgreSQL timestamp format
|
|
||||||
-- while new ones have ISO 8601 format
|
|
||||||
|
|
||||||
UPDATE "environment"
|
|
||||||
SET "createdAt" = to_char("createdAt"::timestamptz, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
|
|
||||||
WHERE "createdAt" NOT LIKE '%T%';
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user