Merge branch 'canary' into feat/stack-env-support

This commit is contained in:
Mauricio Siu
2025-01-30 23:39:54 -06:00
614 changed files with 71918 additions and 18508 deletions

View File

@@ -1,119 +0,0 @@
version: 2.1
jobs:
build-amd64:
machine:
image: ubuntu-2004:current
steps:
- checkout
- run:
name: Prepare .env file
command: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- run:
name: Build and push AMD64 image
command: |
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then
TAG="latest"
elif [ "${CIRCLE_BRANCH}" == "canary" ]; then
TAG="canary"
else
TAG="feature"
fi
docker build --platform linux/amd64 -t dokploy/dokploy:${TAG}-amd64 .
docker push dokploy/dokploy:${TAG}-amd64
build-arm64:
machine:
image: ubuntu-2004:current
resource_class: arm.large
steps:
- checkout
- run:
name: Prepare .env file
command: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- run:
name: Build and push ARM64 image
command: |
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then
TAG="latest"
elif [ "${CIRCLE_BRANCH}" == "canary" ]; then
TAG="canary"
else
TAG="feature"
fi
docker build --platform linux/arm64 -t dokploy/dokploy:${TAG}-arm64 .
docker push dokploy/dokploy:${TAG}-arm64
combine-manifests:
docker:
- image: cimg/node:18.18.0
steps:
- checkout
- setup_remote_docker
- run:
name: Create and push multi-arch manifest
command: |
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_TOKEN
if [ "${CIRCLE_BRANCH}" == "main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo $VERSION
TAG="latest"
docker manifest create dokploy/dokploy:${TAG} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${TAG}
docker manifest create dokploy/dokploy:${VERSION} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${VERSION}
elif [ "${CIRCLE_BRANCH}" == "canary" ]; then
TAG="canary"
docker manifest create dokploy/dokploy:${TAG} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${TAG}
else
TAG="feature"
docker manifest create dokploy/dokploy:${TAG} \
dokploy/dokploy:${TAG}-amd64 \
dokploy/dokploy:${TAG}-arm64
docker manifest push dokploy/dokploy:${TAG}
fi
workflows:
build-all:
jobs:
- build-amd64:
filters:
branches:
only:
- main
- canary
- 379-preview-deployment
- build-arm64:
filters:
branches:
only:
- main
- canary
- 379-preview-deployment
- combine-manifests:
requires:
- build-amd64
- build-arm64
filters:
branches:
only:
- main
- canary
- 379-preview-deployment

BIN
.github/sponsors/its.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
.github/sponsors/light-node.webp vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

83
.github/workflows/create-pr.yml vendored Normal file
View File

@@ -0,0 +1,83 @@
name: Auto PR to main when version changes
on:
push:
branches:
- canary
permissions:
contents: write
pull-requests: write
jobs:
create-pr:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version from package.json
id: package_version
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
- name: Get latest GitHub tag
id: latest_tag
run: |
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
echo $LATEST_TAG
- name: Compare versions
id: compare_versions
run: |
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
VERSION_CHANGED="true"
else
VERSION_CHANGED="false"
fi
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
echo "Comparing versions:"
echo "Current version: ${{ env.VERSION }}"
echo "Latest tag: ${{ env.LATEST_TAG }}"
echo "Version changed: $VERSION_CHANGED"
- name: Check if a PR already exists
id: check_pr
run: |
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
- name: Create Pull Request
if: env.VERSION_CHANGED == 'true' && env.PR_EXISTS == '0'
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main
git checkout canary
git push origin canary
gh pr create \
--title "🚀 Release ${{ env.VERSION }}" \
--body '
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
### 🔍 Changes Include:
- Version bump to ${{ env.VERSION }}
- All changes from canary branch
### ✅ Pre-merge Checklist:
- [ ] All tests passing
- [ ] Documentation updated
- [ ] Docker images built and tested
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
--base main \
--head canary \
--label "release" --label "automated pr" || true \
--reviewer siumauricio \
--assignee siumauricio
env:
GH_TOKEN: ${{ github.token }}

161
.github/workflows/dokploy.yml vendored Normal file
View File

@@ -0,0 +1,161 @@
name: Dokploy Docker Build
on:
push:
branches: [main, canary, feat/github-runners]
env:
IMAGE_NAME: dokploy/dokploy
jobs:
docker-amd:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set tag and version
id: meta
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
docker-arm:
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set tag and version
id: meta
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAG="latest"
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
else
TAG="feature"
fi
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
- name: Prepare env file
run: |
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
combine-manifests:
needs: [docker-amd, docker-arm]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push manifests
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
TAG="latest"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
docker buildx imagetools create -t ${IMAGE_NAME}:${VERSION} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
TAG="canary"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
else
TAG="feature"
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
${IMAGE_NAME}:${TAG}-amd64 \
${IMAGE_NAME}:${TAG}-arm64
fi
generate-release:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version
id: get_version
run: |
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.get_version.outputs.version }}
name: ${{ steps.get_version.outputs.version }}
generate_release_notes: true
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -12,7 +12,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 18.18.0
node-version: 20.9.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run server:build
@@ -26,7 +26,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 18.18.0
node-version: 20.9.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run server:build
@@ -39,7 +39,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 18.18.0
node-version: 20.9.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run server:build

1
.gitignore vendored
View File

@@ -34,7 +34,6 @@ yarn-debug.log*
yarn-error.log*
# Editor
.vscode
.idea
# Misc

2
.nvmrc
View File

@@ -1 +1 @@
18.18.0
20.9.0

View File

@@ -14,12 +14,10 @@ We have a few guidelines to follow when contributing to this project:
## Commit Convention
Before you create a Pull Request, please make sure your commit message follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
### Commit Message Format
```
<type>[optional scope]: <description>
@@ -54,6 +52,8 @@ feat: add new feature
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
We use Node v20.9.0
```bash
git clone https://github.com/dokploy/dokploy.git
cd dokploy
@@ -73,9 +73,10 @@ Run the command that will spin up all the required services and files.
pnpm run dokploy:setup
```
Run this script
Run this script
```bash
pnpm run server:script
pnpm run server:script
```
Now run the development server.
@@ -169,6 +170,7 @@ Let's take the example of `plausible` template.
```typescript
// EXAMPLE
import {
generateBase64,
generateHash,
generateRandomDomain,
type Template,
@@ -200,8 +202,8 @@ export function generate(schema: Schema): Template {
const mounts: Template["mounts"] = [
{
mountPath: "./clickhouse/clickhouse-config.xml",
content: `some content......`,
filePath: "./clickhouse/clickhouse-config.xml",
content: "some content......",
},
];
@@ -247,4 +249,3 @@ export function generate(schema: Schema): Template {
## Docs & Website
To contribute to the Dokploy docs or website, please go to this [repository](https://github.com/Dokploy/website).

View File

@@ -1,4 +1,4 @@
FROM node:18-slim AS base
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -7,7 +7,7 @@ FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
@@ -29,7 +29,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl unzip apache2-utils && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y curl unzip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@@ -48,6 +48,8 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
# Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash
ARG NIXPACKS_VERSION=1.29.1
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh \

View File

@@ -1,4 +1,4 @@
FROM node:18-slim AS base
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -7,7 +7,7 @@ FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server --filter=./apps/dokploy install --frozen-lockfile

View File

@@ -1,4 +1,4 @@
FROM node:18-slim AS base
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -7,7 +7,7 @@ FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server --filter=./apps/schedules install --frozen-lockfile

View File

@@ -1,4 +1,4 @@
FROM node:18-slim AS base
FROM node:20-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -7,7 +7,7 @@ FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y python3 make g++ git python3-pip pkg-config libsecret-1-dev && rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server --filter=./apps/api install --frozen-lockfile

View File

@@ -71,6 +71,9 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<a href="https://mandarin3d.com/?ref=dokploy" target="_blank" style="display: inline-block;">
<img src=".github/sponsors/mandarin.png" alt="Mandarin" height="50"/>
</a>
<a href="https://lightnode.com/?ref=dokploy" target="_blank" style="display: inline-block;">
<img src=".github/sponsors/light-node.webp" alt="Lightnode" height="70"/>
</a>
</div>
### Premium Supporters 🥇
@@ -89,6 +92,7 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<a href="https://lightspeed.run/?ref=dokploy"><img src="https://github.com/lightspeedrun.png" width="60px" alt="Lightspeed.run"/></a>
<a href="https://cloudblast.io/?ref=dokploy "><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
<a href="https://startupfa.me/?ref=dokploy "><img src=".github/sponsors/startupfame.png" width="65px" alt="Startupfame"/></a>
<a href="https://itsdb-center.com?ref=dokploy "><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
</div>
### Community Backers 🤝

View File

@@ -5,7 +5,7 @@
"scripts": {
"dev": "PORT=4000 tsx watch src/index.ts",
"build": "tsc --project tsconfig.json",
"start": "node --experimental-specifier-resolution=node dist/index.js",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {

View File

@@ -4,9 +4,9 @@ import "dotenv/config";
import { zValidator } from "@hono/zod-validator";
import { Queue } from "@nerimity/mimiqueue";
import { createClient } from "redis";
import { logger } from "./logger";
import { type DeployJob, deployJobSchema } from "./schema";
import { deploy } from "./utils";
import { logger } from "./logger.js";
import { type DeployJob, deployJobSchema } from "./schema.js";
import { deploy } from "./utils.js";
const app = new Hono();
const redisClient = createClient({

View File

@@ -1 +1 @@
18.18.0
20.9.0

View File

@@ -0,0 +1,98 @@
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
import { describe, expect, it } from "vitest";
describe("GitHub Webhook Skip CI", () => {
const mockGithubHeaders = {
"x-github-event": "push",
};
const createMockBody = (message: string) => ({
head_commit: {
message,
},
});
const skipKeywords = [
"[skip ci]",
"[ci skip]",
"[no ci]",
"[skip actions]",
"[actions skip]",
];
it("should detect skip keywords in commit message", () => {
for (const keyword of skipKeywords) {
const message = `feat: add new feature ${keyword}`;
const commitMessage = extractCommitMessage(
mockGithubHeaders,
createMockBody(message),
);
expect(commitMessage.includes(keyword)).toBe(true);
}
});
it("should not detect skip keywords in normal commit message", () => {
const message = "feat: add new feature";
const commitMessage = extractCommitMessage(
mockGithubHeaders,
createMockBody(message),
);
for (const keyword of skipKeywords) {
expect(commitMessage.includes(keyword)).toBe(false);
}
});
it("should handle different webhook sources", () => {
// GitHub
expect(
extractCommitMessage(
{ "x-github-event": "push" },
{ head_commit: { message: "[skip ci] test" } },
),
).toBe("[skip ci] test");
// GitLab
expect(
extractCommitMessage(
{ "x-gitlab-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
// Bitbucket
expect(
extractCommitMessage(
{ "x-event-key": "repo:push" },
{
push: {
changes: [{ new: { target: { message: "[skip ci] test" } } }],
},
},
),
).toBe("[skip ci] test");
// Gitea
expect(
extractCommitMessage(
{ "x-gitea-event": "push" },
{ commits: [{ message: "[skip ci] test" }] },
),
).toBe("[skip ci] test");
});
it("should handle missing commit message", () => {
expect(extractCommitMessage(mockGithubHeaders, {})).toBe("NEW COMMIT");
expect(extractCommitMessage({ "x-gitlab-event": "push" }, {})).toBe(
"NEW COMMIT",
);
expect(
extractCommitMessage(
{ "x-event-key": "repo:push" },
{ push: { changes: [] } },
),
).toBe("NEW COMMIT");
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
"NEW COMMIT",
);
});
});

View File

@@ -14,6 +14,9 @@ import {
import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: Admin = {
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
createdAt: "",
authId: "",
adminId: "string",

View File

@@ -13,6 +13,7 @@ export default defineConfig({
NODE: "test",
},
},
plugins: [tsconfigPaths()],
resolve: {
alias: {
"@dokploy/server": path.resolve(

View File

@@ -13,10 +13,12 @@ import { CardTitle } from "@/components/ui/card";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import { AlertTriangle } from "lucide-react";
import { useRouter } from "next/router";
import { useEffect } from "react";
@@ -87,25 +89,31 @@ export const Login2FA = ({ authId }: Props) => {
</span>
</div>
)}
<CardTitle className="text-xl font-bold">2FA Setup</CardTitle>
<CardTitle className="text-xl font-bold">2FA Login</CardTitle>
<FormField
control={form.control}
name="pin"
render={({ field }) => (
<FormItem className="flex flex-col justify-center max-sm:items-center">
<FormItem className="flex flex-col max-sm:items-center">
<FormLabel>Pin</FormLabel>
<FormControl>
<InputOTP maxLength={6} {...field}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<div className="flex">
<InputOTP
maxLength={6}
{...field}
pattern={REGEXP_ONLY_DIGITS}
>
<InputOTPGroup>
<InputOTPSlot index={0} className="border-border" />
<InputOTPSlot index={1} className="border-border" />
<InputOTPSlot index={2} className="border-border" />
<InputOTPSlot index={3} className="border-border" />
<InputOTPSlot index={4} className="border-border" />
<InputOTPSlot index={5} className="border-border" />
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormDescription>
Please enter the 6 digits code provided by your authenticator

View File

@@ -259,7 +259,7 @@ export const AddSwarmSettings = ({ applicationId }: Props) => {
refetch();
})
.catch(() => {
toast.error("Error to update the swarm settings");
toast.error("Error updating the swarm settings");
});
};
return (

View File

@@ -94,7 +94,7 @@ export const ShowClusterSettings = ({ applicationId }: Props) => {
});
})
.catch(() => {
toast.error("Error to update the command");
toast.error("Error updating the command");
});
};

View File

@@ -71,7 +71,7 @@ export const AddCommand = ({ applicationId }: Props) => {
});
})
.catch(() => {
toast.error("Error to update the command");
toast.error("Error updating the command");
});
};
@@ -81,7 +81,8 @@ export const AddCommand = ({ applicationId }: Props) => {
<div>
<CardTitle className="text-xl">Run Command</CardTitle>
<CardDescription>
Run a custom command in the container
Run a custom command in the container after the application
initialized
</CardDescription>
</div>
</CardHeader>

View File

@@ -1,63 +0,0 @@
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";
import { TrashIcon } from "lucide-react";
import { toast } from "sonner";
interface Props {
portId: string;
}
export const DeletePort = ({ portId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.port.delete.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the port
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
portId,
})
.then((data) => {
utils.application.one.invalidate({
applicationId: data?.applicationId,
});
toast.success("Port delete succesfully");
})
.catch(() => {
toast.error("Error to delete the port");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -27,7 +27,7 @@ import {
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -45,18 +45,29 @@ type AddPort = z.infer<typeof AddPortSchema>;
interface Props {
applicationId: string;
portId?: string;
children?: React.ReactNode;
}
export const AddPort = ({
export const HandlePorts = ({
applicationId,
portId,
children = <PlusIcon className="h-4 w-4" />,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } =
api.port.create.useMutation();
const { data } = api.port.one.useQuery(
{
portId: portId ?? "",
},
{
enabled: !!portId,
},
);
const { mutateAsync, isLoading, error, isError } = portId
? api.port.update.useMutation()
: api.port.create.useMutation();
const form = useForm<AddPort>({
defaultValues: {
@@ -68,32 +79,46 @@ export const AddPort = ({
useEffect(() => {
form.reset({
publishedPort: 0,
targetPort: 0,
publishedPort: data?.publishedPort ?? 0,
targetPort: data?.targetPort ?? 0,
protocol: data?.protocol ?? "tcp",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (data: AddPort) => {
await mutateAsync({
applicationId,
...data,
portId: portId || "",
})
.then(async () => {
toast.success("Port Created");
toast.success(portId ? "Port Updated" : "Port Created");
await utils.application.one.invalidate({
applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the port");
toast.error(
portId ? "Error updating the port" : "Error creating the port",
);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{children}</Button>
{portId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button>{children}</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
@@ -204,7 +229,7 @@ export const AddPort = ({
form="hook-form-add-port"
type="submit"
>
Create
{portId ? "Update" : "Create"}
</Button>
</DialogFooter>
</Form>

View File

@@ -1,4 +1,6 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -7,23 +9,25 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Rss } from "lucide-react";
import { Rss, Trash2 } from "lucide-react";
import React from "react";
import { AddPort } from "./add-port";
import { DeletePort } from "./delete-port";
import { UpdatePort } from "./update-port";
import { toast } from "sonner";
import { HandlePorts } from "./handle-ports";
interface Props {
applicationId: string;
}
export const ShowPorts = ({ applicationId }: Props) => {
const { data } = api.application.one.useQuery(
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync: deletePort, isLoading: isRemoving } =
api.port.delete.useMutation();
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
@@ -35,7 +39,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
</div>
{data && data?.ports.length > 0 && (
<AddPort applicationId={applicationId}>Add Port</AddPort>
<HandlePorts applicationId={applicationId}>Add Port</HandlePorts>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
@@ -45,7 +49,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
<span className="text-base text-muted-foreground">
No ports configured
</span>
<AddPort applicationId={applicationId}>Add Port</AddPort>
<HandlePorts applicationId={applicationId}>Add Port</HandlePorts>
</div>
) : (
<div className="flex flex-col pt-2 gap-4">
@@ -78,8 +82,36 @@ export const ShowPorts = ({ applicationId }: Props) => {
</div>
</div>
<div className="flex flex-row gap-4">
<UpdatePort portId={port.portId} />
<DeletePort portId={port.portId} />
<HandlePorts
applicationId={applicationId}
portId={port.portId}
/>
<DialogAction
title="Delete Port"
description="Are you sure you want to delete this port?"
type="destructive"
onClick={async () => {
await deletePort({
portId: port.portId,
})
.then(() => {
refetch();
toast.success("Port deleted successfully");
})
.catch(() => {
toast.error("Error deleting port");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>

View File

@@ -1,195 +0,0 @@
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const UpdatePortSchema = z.object({
publishedPort: z.number().int().min(1).max(65535),
targetPort: z.number().int().min(1).max(65535),
protocol: z.enum(["tcp", "udp"], {
required_error: "Protocol is required",
invalid_type_error: "Protocol must be a valid protocol",
}),
});
type UpdatePort = z.infer<typeof UpdatePortSchema>;
interface Props {
portId: string;
}
export const UpdatePort = ({ portId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { data } = api.port.one.useQuery(
{
portId,
},
{
enabled: !!portId,
},
);
const { mutateAsync, isLoading, error, isError } =
api.port.update.useMutation();
const form = useForm<UpdatePort>({
defaultValues: {},
resolver: zodResolver(UpdatePortSchema),
});
useEffect(() => {
if (data) {
form.reset({
publishedPort: data.publishedPort,
targetPort: data.targetPort,
protocol: data.protocol,
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdatePort) => {
await mutateAsync({
portId,
publishedPort: data.publishedPort,
targetPort: data.targetPort,
protocol: data.protocol,
})
.then(async (response) => {
toast.success("Port Updated");
await utils.application.one.invalidate({
applicationId: response?.applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the port");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update</DialogTitle>
<DialogDescription>Update the port</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-update-redirect"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="publishedPort"
render={({ field }) => (
<FormItem>
<FormLabel>Published Port</FormLabel>
<FormControl>
<NumberInput placeholder="1-65535" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="targetPort"
render={({ field }) => (
<FormItem>
<FormLabel>Target Port</FormLabel>
<FormControl>
<NumberInput placeholder="1-65535" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="protocol"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>Protocol</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent defaultValue={"none"}>
<SelectItem value={"none"} disabled>
None
</SelectItem>
<SelectItem value={"tcp"}>TCP</SelectItem>
<SelectItem value={"udp"}>UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
</div>
</form>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-redirect"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,66 +0,0 @@
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";
import { TrashIcon } from "lucide-react";
import { toast } from "sonner";
interface Props {
redirectId: string;
}
export const DeleteRedirect = ({ redirectId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.redirects.delete.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
redirect
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
redirectId,
})
.then((data) => {
utils.application.one.invalidate({
applicationId: data?.applicationId,
});
utils.application.readTraefikConfig.invalidate({
applicationId: data?.applicationId,
});
toast.success("Redirect delete succesfully");
})
.catch(() => {
toast.error("Error to delete the redirect");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -31,7 +31,7 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -77,19 +77,32 @@ const redirectPresets = [
interface Props {
applicationId: string;
redirectId?: string;
children?: React.ReactNode;
}
export const AddRedirect = ({
export const HandleRedirect = ({
applicationId,
redirectId,
children = <PlusIcon className="w-4 h-4" />,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [presetSelected, setPresetSelected] = useState("");
const { data, refetch } = api.redirects.one.useQuery(
{
redirectId: redirectId || "",
},
{
enabled: !!redirectId,
},
);
const utils = api.useUtils();
const { mutateAsync, isLoading, error, isError } =
api.redirects.create.useMutation();
const { mutateAsync, isLoading, error, isError } = redirectId
? api.redirects.update.useMutation()
: api.redirects.create.useMutation();
const form = useForm<AddRedirect>({
defaultValues: {
@@ -102,29 +115,35 @@ export const AddRedirect = ({
useEffect(() => {
form.reset({
permanent: false,
regex: "",
replacement: "",
permanent: data?.permanent || false,
regex: data?.regex || "",
replacement: data?.replacement || "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (data: AddRedirect) => {
await mutateAsync({
applicationId,
...data,
redirectId: redirectId || "",
})
.then(async () => {
toast.success("Redirect Created");
toast.success(redirectId ? "Redirect Updated" : "Redirect Created");
await utils.application.one.invalidate({
applicationId,
});
refetch();
await utils.application.readTraefikConfig.invalidate({
applicationId,
});
onDialogToggle(false);
})
.catch(() => {
toast.error("Error to create the redirect");
toast.error(
redirectId
? "Error updating the redirect"
: "Error creating the redirect",
);
});
};
@@ -148,7 +167,17 @@ export const AddRedirect = ({
return (
<Dialog open={isOpen} onOpenChange={onDialogToggle}>
<DialogTrigger asChild>
<Button>{children}</Button>
{redirectId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button>{children}</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
@@ -243,7 +272,7 @@ export const AddRedirect = ({
form="hook-form-add-redirect"
type="submit"
>
Create
{redirectId ? "Update" : "Create"}
</Button>
</DialogFooter>
</Form>

View File

@@ -1,3 +1,5 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -6,23 +8,28 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Split } from "lucide-react";
import { Split, Trash2 } from "lucide-react";
import React from "react";
import { AddRedirect } from "./add-redirect";
import { DeleteRedirect } from "./delete-redirect";
import { UpdateRedirect } from "./update-redirect";
import { toast } from "sonner";
import { HandleRedirect } from "./handle-redirect";
interface Props {
applicationId: string;
}
export const ShowRedirects = ({ applicationId }: Props) => {
const { data } = api.application.one.useQuery(
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync: deleteRedirect, isLoading: isRemoving } =
api.redirects.delete.useMutation();
const utils = api.useUtils();
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
@@ -35,7 +42,9 @@ export const ShowRedirects = ({ applicationId }: Props) => {
</div>
{data && data?.redirects.length > 0 && (
<AddRedirect applicationId={applicationId}>Add Redirect</AddRedirect>
<HandleRedirect applicationId={applicationId}>
Add Redirect
</HandleRedirect>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
@@ -45,9 +54,9 @@ export const ShowRedirects = ({ applicationId }: Props) => {
<span className="text-base text-muted-foreground">
No redirects configured
</span>
<AddRedirect applicationId={applicationId}>
<HandleRedirect applicationId={applicationId}>
Add Redirect
</AddRedirect>
</HandleRedirect>
</div>
) : (
<div className="flex flex-col pt-2">
@@ -76,8 +85,40 @@ export const ShowRedirects = ({ applicationId }: Props) => {
</div>
</div>
<div className="flex flex-row gap-4">
<UpdateRedirect redirectId={redirect.redirectId} />
<DeleteRedirect redirectId={redirect.redirectId} />
<HandleRedirect
redirectId={redirect.redirectId}
applicationId={applicationId}
/>
<DialogAction
title="Delete Redirect"
description="Are you sure you want to delete this redirect?"
type="destructive"
onClick={async () => {
await deleteRedirect({
redirectId: redirect.redirectId,
})
.then(() => {
refetch();
utils.application.readTraefikConfig.invalidate({
applicationId,
});
toast.success("Redirect deleted successfully");
})
.catch(() => {
toast.error("Error deleting redirect");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>

View File

@@ -1,182 +0,0 @@
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 {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const UpdateRedirectSchema = z.object({
regex: z.string().min(1, "Regex required"),
permanent: z.boolean().default(false),
replacement: z.string().min(1, "Replacement required"),
});
type UpdateRedirect = z.infer<typeof UpdateRedirectSchema>;
interface Props {
redirectId: string;
}
export const UpdateRedirect = ({ redirectId }: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { data } = api.redirects.one.useQuery(
{
redirectId,
},
{
enabled: !!redirectId,
},
);
const { mutateAsync, isLoading, error, isError } =
api.redirects.update.useMutation();
const form = useForm<UpdateRedirect>({
defaultValues: {
permanent: false,
regex: "",
replacement: "",
},
resolver: zodResolver(UpdateRedirectSchema),
});
useEffect(() => {
if (data) {
form.reset({
permanent: data.permanent || false,
regex: data.regex || "",
replacement: data.replacement || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateRedirect) => {
await mutateAsync({
redirectId,
permanent: data.permanent,
regex: data.regex,
replacement: data.replacement,
})
.then(async (response) => {
toast.success("Redirect Updated");
await utils.application.one.invalidate({
applicationId: response?.applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the redirect");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update</DialogTitle>
<DialogDescription>Update the redirect</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-update-redirect"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="regex"
render={({ field }) => (
<FormItem>
<FormLabel>Regex</FormLabel>
<FormControl>
<Input placeholder="^http://localhost/(.*)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="replacement"
render={({ field }) => (
<FormItem>
<FormLabel>Replacement</FormLabel>
<FormControl>
<Input placeholder="http://mydomain/$${1}" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="permanent"
render={({ field }) => (
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Permanent</FormLabel>
<FormDescription>
Set the permanent option to true to apply a permanent
redirection.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</form>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-redirect"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,66 +0,0 @@
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";
import { TrashIcon } from "lucide-react";
import { toast } from "sonner";
interface Props {
securityId: string;
}
export const DeleteSecurity = ({ securityId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } = api.security.delete.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
security
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
securityId,
})
.then((data) => {
utils.application.one.invalidate({
applicationId: data?.applicationId,
});
utils.application.readTraefikConfig.invalidate({
applicationId: data?.applicationId,
});
toast.success("Security delete succesfully");
})
.catch(() => {
toast.error("Error to delete the security");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -20,7 +20,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -35,17 +35,29 @@ type AddSecurity = z.infer<typeof AddSecuritychema>;
interface Props {
applicationId: string;
securityId?: string;
children?: React.ReactNode;
}
export const AddSecurity = ({
export const HandleSecurity = ({
applicationId,
securityId,
children = <PlusIcon className="h-4 w-4" />,
}: Props) => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isLoading, error, isError } =
api.security.create.useMutation();
const { data } = api.security.one.useQuery(
{
securityId: securityId ?? "",
},
{
enabled: !!securityId,
},
);
const { mutateAsync, isLoading, error, isError } = securityId
? api.security.update.useMutation()
: api.security.create.useMutation();
const form = useForm<AddSecurity>({
defaultValues: {
@@ -56,16 +68,20 @@ export const AddSecurity = ({
});
useEffect(() => {
form.reset();
}, [form, form.reset, form.formState.isSubmitSuccessful]);
form.reset({
username: data?.username || "",
password: data?.password || "",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (data: AddSecurity) => {
await mutateAsync({
applicationId,
...data,
securityId: securityId || "",
})
.then(async () => {
toast.success("Security Created");
toast.success(securityId ? "Security Updated" : "Security Created");
await utils.application.one.invalidate({
applicationId,
});
@@ -75,20 +91,34 @@ export const AddSecurity = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the security");
toast.error(
securityId
? "Error updating the security"
: "Error creating security",
);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>{children}</Button>
{securityId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button>{children}</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Security</DialogTitle>
<DialogDescription>
Add security to your application
{securityId ? "Update" : "Add"} security to your application
</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
@@ -137,7 +167,7 @@ export const AddSecurity = ({
form="hook-form-add-security"
type="submit"
>
Create
{securityId ? "Update" : "Create"}
</Button>
</DialogFooter>
</Form>

View File

@@ -1,3 +1,5 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -6,23 +8,27 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { LockKeyhole } from "lucide-react";
import { LockKeyhole, Trash2 } from "lucide-react";
import React from "react";
import { AddSecurity } from "./add-security";
import { DeleteSecurity } from "./delete-security";
import { UpdateSecurity } from "./update-security";
import { toast } from "sonner";
import { HandleSecurity } from "./handle-security";
interface Props {
applicationId: string;
}
export const ShowSecurity = ({ applicationId }: Props) => {
const { data } = api.application.one.useQuery(
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync: deleteSecurity, isLoading: isRemoving } =
api.security.delete.useMutation();
const utils = api.useUtils();
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
@@ -32,7 +38,9 @@ export const ShowSecurity = ({ applicationId }: Props) => {
</div>
{data && data?.security.length > 0 && (
<AddSecurity applicationId={applicationId}>Add Security</AddSecurity>
<HandleSecurity applicationId={applicationId}>
Add Security
</HandleSecurity>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
@@ -42,9 +50,9 @@ export const ShowSecurity = ({ applicationId }: Props) => {
<span className="text-base text-muted-foreground">
No security configured
</span>
<AddSecurity applicationId={applicationId}>
<HandleSecurity applicationId={applicationId}>
Add Security
</AddSecurity>
</HandleSecurity>
</div>
) : (
<div className="flex flex-col pt-2">
@@ -67,8 +75,39 @@ export const ShowSecurity = ({ applicationId }: Props) => {
</div>
</div>
<div className="flex flex-row gap-2">
<UpdateSecurity securityId={security.securityId} />
<DeleteSecurity securityId={security.securityId} />
<HandleSecurity
securityId={security.securityId}
applicationId={applicationId}
/>
<DialogAction
title="Delete Security"
description="Are you sure you want to delete this security?"
type="destructive"
onClick={async () => {
await deleteSecurity({
securityId: security.securityId,
})
.then(() => {
refetch();
utils.application.readTraefikConfig.invalidate({
applicationId,
});
toast.success("Security deleted successfully");
})
.catch(() => {
toast.error("Error deleting security");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>

View File

@@ -1,155 +0,0 @@
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 {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const UpdateSecuritySchema = z.object({
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
});
type UpdateSecurity = z.infer<typeof UpdateSecuritySchema>;
interface Props {
securityId: string;
}
export const UpdateSecurity = ({ securityId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { data } = api.security.one.useQuery(
{
securityId,
},
{
enabled: !!securityId,
},
);
const { mutateAsync, isLoading, error, isError } =
api.security.update.useMutation();
const form = useForm<UpdateSecurity>({
defaultValues: {
username: "",
password: "",
},
resolver: zodResolver(UpdateSecuritySchema),
});
useEffect(() => {
if (data) {
form.reset({
username: data.username || "",
password: data.password || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (data: UpdateSecurity) => {
await mutateAsync({
securityId,
username: data.username,
password: data.password,
})
.then(async (response) => {
toast.success("Security Updated");
await utils.application.one.invalidate({
applicationId: response?.applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the security");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<PenBoxIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update</DialogTitle>
<DialogDescription>Update the security</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
id="hook-form-update-security"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 "
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="test1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="test" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-update-security"
type="submit"
>
Update
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,231 +0,0 @@
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,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addResourcesApplication = z.object({
memoryReservation: z.number().nullable().optional(),
cpuLimit: z.number().nullable().optional(),
memoryLimit: z.number().nullable().optional(),
cpuReservation: z.number().nullable().optional(),
});
interface Props {
applicationId: string;
}
type AddResourcesApplication = z.infer<typeof addResourcesApplication>;
export const ShowApplicationResources = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync, isLoading } = api.application.update.useMutation();
const form = useForm<AddResourcesApplication>({
defaultValues: {},
resolver: zodResolver(addResourcesApplication),
});
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: AddResourcesApplication) => {
await mutateAsync({
applicationId,
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error to Update the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific.
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<FormLabel>Memory Reservation</FormLabel>
<FormControl>
<Input
placeholder="256 MB"
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Memory Limit</FormLabel>
<FormControl>
<Input
placeholder={"1024 MB"}
{...field}
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange(null);
} else {
const number = Number.parseInt(value, 10);
if (!Number.isNaN(number)) {
field.onChange(number);
}
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Limit</FormLabel>
<FormControl>
<Input
placeholder={"2"}
{...field}
type="number"
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (
value === "" ||
/^[0-9]*\.?[0-9]*$/.test(value)
) {
const float = Number.parseFloat(value);
field.onChange(float);
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Cpu Reservation</FormLabel>
<FormControl>
<Input
placeholder={"1"}
{...field}
type="number"
value={field.value?.toString() || ""}
onChange={(e) => {
const value = e.target.value;
if (
value === "" ||
/^[0-9]*\.?[0-9]*$/.test(value)
) {
const float = Number.parseFloat(value);
field.onChange(float);
}
}}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,287 @@
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,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { InfoIcon } from "lucide-react";
import React, { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addResourcesSchema = z.object({
memoryReservation: z.string().optional(),
cpuLimit: z.string().optional(),
memoryLimit: z.string().optional(),
cpuReservation: z.string().optional(),
});
export type ServiceType =
| "postgres"
| "mongo"
| "redis"
| "mysql"
| "mariadb"
| "application";
interface Props {
id: string;
type: ServiceType | "application";
}
type AddResources = z.infer<typeof addResourcesSchema>;
export const ShowResources = ({ id, type }: Props) => {
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
application: () => api.application.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<AddResources>({
defaultValues: {
cpuLimit: "",
cpuReservation: "",
memoryLimit: "",
memoryReservation: "",
},
resolver: zodResolver(addResourcesSchema),
});
useEffect(() => {
if (data) {
form.reset({
cpuLimit: data?.cpuLimit || undefined,
cpuReservation: data?.cpuReservation || undefined,
memoryLimit: data?.memoryLimit || undefined,
memoryReservation: data?.memoryReservation || undefined,
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: AddResources) => {
await mutateAsync({
mongoId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
applicationId: id || "",
cpuLimit: formData.cpuLimit || null,
cpuReservation: formData.cpuReservation || null,
memoryLimit: formData.memoryLimit || null,
memoryReservation: formData.memoryReservation || null,
})
.then(async () => {
toast.success("Resources Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating the resources");
});
};
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Resources</CardTitle>
<CardDescription>
If you want to decrease or increase the resources to a specific.
application or database
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after modify the resources to apply
the changes.
</AlertBlock>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="grid w-full md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="memoryLimit"
render={({ field }) => {
return (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Memory Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
Memory hard limit in bytes. Example: 1GB =
1073741824 bytes
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
placeholder="1073741824 (1GB in bytes)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="memoryReservation"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Memory Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
Memory soft limit in bytes. Example: 256MB =
268435456 bytes
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
placeholder="268435456 (256MB in bytes)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cpuLimit"
render={({ field }) => {
return (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>CPU Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
CPU quota in units of 10^-9 CPUs. Example: 2
CPUs = 2000000000
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input
placeholder="2000000000 (2 CPUs)"
{...field}
value={field.value?.toString() || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="cpuReservation"
render={({ field }) => {
return (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>CPU Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p>
CPU shares (relative weight). Example: 1 CPU =
1000000000
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<Input placeholder="1000000000 (1 CPU)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -105,7 +105,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
form.reset();
})
.catch(() => {
toast.error("Error to update the traefik config");
toast.error("Error updating the Traefik config");
});
};

View File

@@ -107,7 +107,7 @@ export const AddVolumes = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the Bind mount");
toast.error("Error creating the Bind mount");
});
} else if (data.type === "volume") {
await mutateAsync({
@@ -122,7 +122,7 @@ export const AddVolumes = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the Volume mount");
toast.error("Error creating the Volume mount");
});
} else if (data.type === "file") {
await mutateAsync({
@@ -138,7 +138,7 @@ export const AddVolumes = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error to create the File mount");
toast.error("Error creating the File mount");
});
}

View File

@@ -1,61 +0,0 @@
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";
import { TrashIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
mountId: string;
refetch: () => void;
}
export const DeleteVolume = ({ mountId, refetch }: Props) => {
const { mutateAsync, isLoading } = api.mounts.remove.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the mount
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
mountId,
})
.then(() => {
refetch();
toast.success("Mount deleted succesfully");
})
.catch(() => {
toast.error("Error to delete the mount");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,4 +1,6 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -7,40 +9,49 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Package } from "lucide-react";
import { Package, Trash2 } from "lucide-react";
import React from "react";
import { toast } from "sonner";
import type { ServiceType } from "../show-resources";
import { AddVolumes } from "./add-volumes";
import { DeleteVolume } from "./delete-volume";
import { UpdateVolume } from "./update-volume";
interface Props {
applicationId: string;
id: string;
type: ServiceType | "compose";
}
export const ShowVolumes = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
export const ShowVolumes = ({ id, type }: Props) => {
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const { mutateAsync: deleteVolume, isLoading: isRemoving } =
api.mounts.remove.useMutation();
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
<div>
<CardTitle className="text-xl">Volumes</CardTitle>
<CardDescription>
If you want to persist data in this application use the following
config to setup the volumes
If you want to persist data in this service use the following config
to setup the volumes
</CardDescription>
</div>
{data && data?.mounts.length > 0 && (
<AddVolumes
serviceId={applicationId}
refetch={refetch}
serviceType="application"
>
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume
</AddVolumes>
)}
@@ -52,17 +63,13 @@ export const ShowVolumes = ({ applicationId }: Props) => {
<span className="text-base text-muted-foreground">
No volumes/mounts configured
</span>
<AddVolumes
serviceId={applicationId}
refetch={refetch}
serviceType="application"
>
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
Add Volume
</AddVolumes>
</div>
) : (
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
<AlertBlock type="warning">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
@@ -73,7 +80,8 @@ export const ShowVolumes = ({ applicationId }: Props) => {
key={mount.mountId}
className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 flex-col gap-4 sm:gap-8">
{/* <Package className="size-8 self-center text-muted-foreground" /> */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 flex-col gap-4 sm:gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Type</span>
<span className="text-sm text-muted-foreground">
@@ -90,21 +98,12 @@ export const ShowVolumes = ({ applicationId }: Props) => {
)}
{mount.type === "file" && (
<>
<div className="flex flex-col gap-1">
<span className="font-medium">Content</span>
<span className="text-sm text-muted-foreground">
{mount.content}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">File Path</span>
<span className="text-sm text-muted-foreground">
{mount.filePath}
</span>
</div>
</>
<div className="flex flex-col gap-1">
<span className="font-medium">Content</span>
<span className="text-sm text-muted-foreground line-clamp-[10] whitespace-break-spaces">
{mount.content}
</span>
</div>
)}
{mount.type === "bind" && (
<div className="flex flex-col gap-1">
@@ -114,21 +113,55 @@ export const ShowVolumes = ({ applicationId }: Props) => {
</span>
</div>
)}
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Path</span>
<span className="text-sm text-muted-foreground">
{mount.mountPath}
</span>
</div>
{mount.type === "file" ? (
<div className="flex flex-col gap-1">
<span className="font-medium">File Path</span>
<span className="text-sm text-muted-foreground">
{mount.filePath}
</span>
</div>
) : (
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Path</span>
<span className="text-sm text-muted-foreground">
{mount.mountPath}
</span>
</div>
)}
</div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType="application"
serviceType={type}
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
<DialogAction
title="Delete Volume"
description="Are you sure you want to delete this volume?"
type="destructive"
onClick={async () => {
await deleteVolume({
mountId: mount.mountId,
})
.then(() => {
refetch();
toast.success("Volume deleted successfully");
})
.catch(() => {
toast.error("Error deleting volume");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
</div>

View File

@@ -21,7 +21,7 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil } from "lucide-react";
import { PenBoxIcon, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -139,7 +139,7 @@ export const UpdateVolume = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the Bind mount");
toast.error("Error updating the Bind mount");
});
} else if (data.type === "volume") {
await mutateAsync({
@@ -153,7 +153,7 @@ export const UpdateVolume = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the Volume mount");
toast.error("Error updating the Volume mount");
});
} else if (data.type === "file") {
await mutateAsync({
@@ -168,7 +168,7 @@ export const UpdateVolume = ({
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the File mount");
toast.error("Error updating the File mount");
});
}
refetch();
@@ -177,8 +177,13 @@ export const UpdateVolume = ({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<Pencil className="size-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
isLoading={isLoading}
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">

View File

@@ -126,7 +126,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to save the build type");
toast.error("Error saving the build type");
});
};

View File

@@ -1,143 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const deleteApplicationSchema = z.object({
projectName: z.string().min(1, {
message: "Application name is required",
}),
});
type DeleteApplication = z.infer<typeof deleteApplicationSchema>;
interface Props {
applicationId: string;
}
export const DeleteApplication = ({ applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isLoading } = api.application.delete.useMutation();
const { data } = api.application.one.useQuery(
{ applicationId },
{ enabled: !!applicationId },
);
const { push } = useRouter();
const form = useForm<DeleteApplication>({
defaultValues: {
projectName: "",
},
resolver: zodResolver(deleteApplicationSchema),
});
const onSubmit = async (formData: DeleteApplication) => {
const expectedName = `${data?.name}/${data?.appName}`;
if (formData.projectName === expectedName) {
await mutateAsync({
applicationId,
})
.then((data) => {
push(`/dashboard/project/${data?.projectId}`);
toast.success("Application deleted successfully");
setIsOpen(false);
})
.catch(() => {
toast.error("Error deleting the application");
});
} else {
form.setError("projectName", {
message: "Project name does not match",
});
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
application. If you are sure please enter the application name to
delete this application.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-delete-application"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
</FormLabel>
<FormControl>
<Input
placeholder="Enter application name to confirm"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
<DialogFooter>
<Button
variant="secondary"
onClick={() => {
setIsOpen(false);
}}
>
Cancel
</Button>
<Button
isLoading={isLoading}
form="hook-form-delete-application"
type="submit"
variant="destructive"
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -20,6 +20,12 @@ interface Props {
export const CancelQueues = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = api.application.cleanQueues.useMutation();
const { data: isCloud } = api.settings.isCloud.useQuery();
if (isCloud) {
return null;
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>

View File

@@ -47,7 +47,7 @@ export const RefreshToken = ({ applicationId }: Props) => {
toast.success("Refresh updated");
})
.catch(() => {
toast.error("Error to update the refresh token");
toast.error("Error updating the refresh token");
});
}}
>

View File

@@ -1,3 +1,5 @@
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -5,18 +7,45 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { TerminalLine } from "../../docker/logs/terminal-line";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
interface Props {
logPath: string | null;
open: boolean;
onClose: () => void;
serverId?: string;
errorMessage?: string;
}
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
export const ShowDeployment = ({
logPath,
open,
onClose,
serverId,
errorMessage,
}: Props) => {
const [data, setData] = useState("");
const endOfLogsRef = useRef<HTMLDivElement>(null);
const [showExtraLogs, setShowExtraLogs] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
};
useEffect(() => {
if (!open || !logPath) return;
@@ -48,13 +77,36 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
};
}, [logPath, open]);
const scrollToBottom = () => {
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
const logs = parseLogs(data);
let filteredLogsResult = logs;
if (serverId) {
let hideSubsequentLogs = false;
filteredLogsResult = logs.filter((log) => {
if (
log.message.includes(
"===================================EXTRA LOGS============================================",
)
) {
hideSubsequentLogs = true;
return showExtraLogs;
}
return showExtraLogs ? true : !hideSubsequentLogs;
});
}
setFilteredLogs(filteredLogsResult);
}, [data, showExtraLogs]);
useEffect(() => {
scrollToBottom();
}, [data]);
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
const optionalErrors = parseLogs(errorMessage || "");
return (
<Dialog
@@ -75,18 +127,57 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription>
See all the details of this deployment
<DialogDescription className="flex items-center gap-2">
<span>
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
{serverId && (
<div className="flex items-center space-x-2">
<Checkbox
id="show-extra-logs"
checked={showExtraLogs}
onCheckedChange={(checked) =>
setShowExtraLogs(checked as boolean)
}
/>
<label
htmlFor="show-extra-logs"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Show Extra Logs
</label>
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
<code>
<pre className="whitespace-pre-wrap break-words">
{data || "Loading..."}
</pre>
<div ref={endOfLogsRef} />
</code>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{" "}
{filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine key={index} log={log} noTimestamp />
))
) : (
<>
{optionalErrors.length > 0 ? (
optionalErrors.map((log: LogLine, index: number) => (
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
))
) : (
<div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>

View File

@@ -8,7 +8,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { type RouterOutputs, api } from "@/utils/api";
import { RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueues } from "./cancel-queues";
@@ -18,8 +18,11 @@ import { ShowDeployment } from "./show-deployment";
interface Props {
applicationId: string;
}
export const ShowDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const { data } = api.application.one.useQuery({ applicationId });
const { data: deployments } = api.deployment.all.useQuery(
{ applicationId },
@@ -100,7 +103,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
<Button
onClick={() => {
setActiveLog(deployment.logPath);
setActiveLog(deployment);
}}
>
View
@@ -112,9 +115,10 @@ export const ShowDeployments = ({ applicationId }: Props) => {
)}
<ShowDeployment
serverId={data?.serverId || ""}
open={activeLog !== null}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</CardContent>
</Card>

View File

@@ -104,9 +104,7 @@ export const AddDomain = ({
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId
? "Error to update the domain"
: "Error to create the domain",
error: domainId ? "Error updating the domain" : "Error creating the domain",
submit: domainId ? "Update" : "Create",
dialogDescription: domainId
? "In this section you can edit a domain"
@@ -264,21 +262,21 @@ export const AddDomain = ({
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
Let's Encrypt
</SelectItem>
</SelectContent>
</Select>

View File

@@ -1,73 +0,0 @@
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";
import { TrashIcon } from "lucide-react";
import React from "react";
import { toast } from "sonner";
interface Props {
domainId: string;
}
export const DeleteDomain = ({ domainId }: Props) => {
const { mutateAsync, isLoading } = api.domain.delete.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground " />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the
domain
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
domainId,
})
.then((data) => {
if (data?.applicationId) {
utils.domain.byApplicationId.invalidate({
applicationId: data?.applicationId,
});
utils.application.readTraefikConfig.invalidate({
applicationId: data?.applicationId,
});
} else if (data?.composeId) {
utils.domain.byComposeId.invalidate({
composeId: data?.composeId,
});
}
toast.success("Domain delete succesfully");
})
.catch(() => {
toast.error("Error to delete Domain");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,3 +1,4 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -8,17 +9,17 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { ExternalLink, GlobeIcon, PenBoxIcon } from "lucide-react";
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AddDomain } from "./add-domain";
import { DeleteDomain } from "./delete-domain";
interface Props {
applicationId: string;
}
export const ShowDomains = ({ applicationId }: Props) => {
const { data } = api.domain.byApplicationId.useQuery(
const { data, refetch } = api.domain.byApplicationId.useQuery(
{
applicationId,
},
@@ -26,6 +27,10 @@ export const ShowDomains = ({ applicationId }: Props) => {
enabled: !!applicationId,
},
);
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
api.domain.delete.useMutation();
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
@@ -93,11 +98,40 @@ export const ShowDomains = ({ applicationId }: Props) => {
applicationId={applicationId}
domainId={item.domainId}
>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
<DeleteDomain domainId={item.domainId} />
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((data) => {
refetch();
toast.success("Domain deleted successfully");
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
);

View File

@@ -18,10 +18,11 @@ import { Toggle } from "@/components/ui/toggle";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import React, { type CSSProperties, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { ServiceType } from "../advanced/show-resources";
const addEnvironmentSchema = z.object({
environment: z.string(),
@@ -30,21 +31,39 @@ const addEnvironmentSchema = z.object({
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
interface Props {
mariadbId: string;
id: string;
type: Exclude<ServiceType | "compose", "application">;
}
export const ShowMariadbEnvironment = ({ mariadbId }: Props) => {
export const ShowEnvironment = ({ id, type }: Props) => {
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const [isEnvVisible, setIsEnvVisible] = useState(true);
const { mutateAsync, isLoading } = api.mariadb.saveEnvironment.useMutation();
const { data, refetch } = api.mariadb.one.useQuery(
{
mariadbId,
},
{
enabled: !!mariadbId,
},
);
const mutationMap = {
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
compose: () => api.compose.update.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
const form = useForm<EnvironmentSchema>({
defaultValues: {
environment: "",
@@ -62,22 +81,26 @@ export const ShowMariadbEnvironment = ({ mariadbId }: Props) => {
const onSubmit = async (data: EnvironmentSchema) => {
mutateAsync({
mongoId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
composeId: id || "",
env: data.environment,
mariadbId,
})
.then(async () => {
toast.success("Environments Added");
await refetch();
})
.catch(() => {
toast.error("Error to add environment");
toast.error("Error adding environment");
});
};
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
{" "}
<CardHeader className="flex flex-row w-full items-center justify-between">
<div>
<CardTitle className="text-xl">Environment Settings</CardTitle>
@@ -112,6 +135,11 @@ export const ShowMariadbEnvironment = ({ mariadbId }: Props) => {
<FormItem className="w-full">
<FormControl>
<CodeEditor
style={
{
WebkitTextSecurity: isEnvVisible ? "disc" : null,
} as CSSProperties
}
language="properties"
disabled={isEnvVisible}
placeholder={`NODE_ENV=production

View File

@@ -51,17 +51,17 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to add environment");
toast.error("Error adding environment");
});
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-5 "
>
<Card className="bg-background p-6">
<Card className="bg-background px-6 pb-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-4"
>
<Secrets
name="env"
title="Environment Settings"
@@ -89,15 +89,13 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
placeholder="NPM_TOKEN=xyz"
/>
)}
<CardContent>
<div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit">
Save
</Button>
</div>
</CardContent>
</Card>
</form>
</Form>
<div className="flex flex-row justify-end">
<Button isLoading={isLoading} className="w-fit" type="submit">
Save
</Button>
</div>
</form>
</Form>
</Card>
);
};

View File

@@ -1,69 +0,0 @@
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";
import { toast } from "sonner";
interface Props {
applicationId: string;
}
export const DeployApplication = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync: deploy } = api.application.deploy.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button isLoading={data?.applicationStatus === "running"}>
Deploy
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will deploy the application
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await deploy({
applicationId,
})
.then(async () => {
toast.success("Application deployed succesfully");
await refetch();
})
.catch(() => {
toast.error("Error to deploy Application");
});
await refetch();
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -137,7 +137,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to save the Bitbucket provider");
toast.error("Error saving the Bitbucket provider");
});
};
@@ -235,7 +235,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -245,7 +245,12 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -68,7 +68,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to save the Docker provider");
toast.error("Error saving the Docker provider");
});
};

View File

@@ -56,7 +56,7 @@ export const SaveDragNDrop = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to save the deployment");
toast.error("Error saving the deployment");
});
};

View File

@@ -84,7 +84,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to save the Git provider");
toast.error("Error saving the Git provider");
});
};

View File

@@ -131,7 +131,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to save the github provider");
toast.error("Error saving the github provider");
});
};
@@ -226,7 +226,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -236,7 +236,12 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -144,7 +144,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to save the gitlab provider");
toast.error("Error saving the gitlab provider");
});
};
@@ -248,7 +248,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
{repositories?.map((repo) => {
return (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -260,7 +260,12 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -1,70 +0,0 @@
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";
import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
interface Props {
applicationId: string;
appName: string;
}
export const ResetApplication = ({ applicationId, appName }: Props) => {
const { refetch } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync: reload, isLoading } =
api.application.reload.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary" isLoading={isLoading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will reload the application
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await reload({
applicationId,
appName,
})
.then(() => {
toast.success("Service Reloaded");
})
.catch(() => {
toast.error("Error to reload the service");
});
await refetch();
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,23 +1,21 @@
import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { Terminal } from "lucide-react";
import { Ban, CheckCircle2, Hammer, RefreshCcw, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import React from "react";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
import { RedbuildApplication } from "../rebuild-application";
import { StartApplication } from "../start-application";
import { StopApplication } from "../stop-application";
import { DeployApplication } from "./deploy-application";
import { ResetApplication } from "./reset-application";
interface Props {
applicationId: string;
}
export const ShowGeneralApplication = ({ applicationId }: Props) => {
const router = useRouter();
const { data, refetch } = api.application.one.useQuery(
{
applicationId,
@@ -25,6 +23,18 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
{ enabled: !!applicationId },
);
const { mutateAsync: update } = api.application.update.useMutation();
const { mutateAsync: start, isLoading: isStarting } =
api.application.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } =
api.application.stop.useMutation();
const { mutateAsync: deploy, isLoading: isDeploying } =
api.application.deploy.useMutation();
const { mutateAsync: reload, isLoading: isReloading } =
api.application.reload.useMutation();
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
return (
<>
@@ -33,17 +43,127 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<DeployApplication applicationId={applicationId} />
<ResetApplication
applicationId={applicationId}
appName={data?.appName || ""}
/>
<DialogAction
title="Deploy Application"
description="Are you sure you want to deploy this application?"
type="default"
onClick={async () => {
await deploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying application");
});
}}
>
<Button
variant="default"
isLoading={data?.applicationStatus === "running"}
>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Reload Application"
description="Are you sure you want to reload this application?"
type="default"
onClick={async () => {
await reload({
applicationId: applicationId,
appName: data?.appName || "",
})
.then(() => {
toast.success("Application reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading application");
});
}}
>
<Button variant="secondary" isLoading={isReloading}>
Reload
<RefreshCcw className="size-4" />
</Button>
</DialogAction>
<DialogAction
title="Rebuild Application"
description="Are you sure you want to rebuild this application?"
type="default"
onClick={async () => {
await redeploy({
applicationId: applicationId,
})
.then(() => {
toast.success("Application rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding application");
});
}}
>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
>
Rebuild
<Hammer className="size-4" />
</Button>
</DialogAction>
<RedbuildApplication applicationId={applicationId} />
{data?.applicationStatus === "idle" ? (
<StartApplication applicationId={applicationId} />
<DialogAction
title="Start Application"
description="Are you sure you want to start this application?"
type="default"
onClick={async () => {
await start({
applicationId: applicationId,
})
.then(() => {
toast.success("Application started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting application");
});
}}
>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
</Button>
</DialogAction>
) : (
<StopApplication applicationId={applicationId} />
<DialogAction
title="Stop Application"
description="Are you sure you want to stop this application?"
onClick={async () => {
await stop({
applicationId: applicationId,
})
.then(() => {
toast.success("Application stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping application");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
</Button>
</DialogAction>
)}
<DockerTerminalModal
appName={data?.appName || ""}
@@ -69,7 +189,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center"

View File

@@ -1,3 +1,4 @@
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
@@ -15,6 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
@@ -29,28 +31,67 @@ export const DockerLogs = dynamic(
},
);
export const badgeStateColor = (state: string) => {
switch (state) {
case "running":
return "green";
case "exited":
case "shutdown":
return "red";
case "accepted":
case "created":
return "blue";
default:
return "default";
}
};
interface Props {
appName: string;
serverId?: string;
}
export const ShowDockerLogs = ({ appName, serverId }: Props) => {
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native");
const { data: services, isLoading: servicesLoading } =
api.docker.getServiceContainersByAppName.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName && option === "swarm",
},
);
const { data: containers, isLoading: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName && option === "native",
},
);
useEffect(() => {
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [data]);
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
option === "native" ? containers?.length : services?.length;
return (
<Card className="bg-background">
@@ -62,7 +103,21 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Label>Select a container to view logs</Label>
<div className="flex flex-row justify-between items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
{option === "native" ? "Native" : "Swarm"}
</span>
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setOption(checked ? "native" : "swarm");
}}
/>
</div>
</div>
<Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger>
{isLoading ? (
@@ -76,22 +131,45 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</SelectTrigger>
<SelectContent>
<SelectGroup>
{data?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}) {container.state}
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>
{option === "native" ? (
<div>
{containers?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}){" "}
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
</div>
) : (
<>
{services?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}@{container.node}
)
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<DockerLogs
serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"}
runType={option}
/>
</CardContent>
</Card>

View File

@@ -104,9 +104,7 @@ export const AddPreviewDomain = ({
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId
? "Error to update the domain"
: "Error to create the domain",
error: domainId ? "Error updating the domain" : "Error creating the domain",
submit: domainId ? "Update" : "Create",
dialogDescription: domainId
? "In this section you can edit a domain"
@@ -265,21 +263,21 @@ export const AddPreviewDomain = ({
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
Let's Encrypt
</SelectItem>
</SelectContent>
</Select>

View File

@@ -18,15 +18,28 @@ import { ShowDeployment } from "../deployments/show-deployment";
interface Props {
deployments: RouterOutputs["deployment"]["all"];
serverId?: string;
trigger?: React.ReactNode;
}
export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
export const ShowPreviewBuilds = ({
deployments,
serverId,
trigger,
}: Props) => {
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">View Builds</Button>
{trigger ? (
trigger
) : (
<Button className="sm:w-auto w-full" size="sm" variant="outline">
View Builds
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
<DialogHeader>
@@ -66,7 +79,7 @@ export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => {
<Button
onClick={() => {
setActiveLog(deployment.logPath);
setActiveLog(deployment);
}}
>
View
@@ -78,9 +91,10 @@ export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => {
</DialogContent>
<ShowDeployment
serverId={serverId || ""}
open={activeLog !== null}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</Dialog>
);

View File

@@ -1,5 +1,8 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -8,30 +11,34 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { Pencil, RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import {
ExternalLink,
FileText,
GitPullRequest,
Layers,
PenSquare,
RocketIcon,
Trash2,
} from "lucide-react";
import React from "react";
import { toast } from "sonner";
import { ShowDeployment } from "../deployments/show-deployment";
import Link from "next/link";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { DialogAction } from "@/components/shared/dialog-action";
import { AddPreviewDomain } from "./add-preview-domain";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { ShowPreviewSettings } from "./show-preview-settings";
import { ShowPreviewBuilds } from "./show-preview-builds";
import { ShowPreviewSettings } from "./show-preview-settings";
interface Props {
applicationId: string;
}
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data } = api.application.one.useQuery({ applicationId });
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
api.previewDeployment.all.useQuery(
{ applicationId },
@@ -39,10 +46,19 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
enabled: !!applicationId,
},
);
// const [url, setUrl] = React.useState("");
// useEffect(() => {
// setUrl(document.location.origin);
// }, []);
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
deletePreviewDeployment({
previewDeploymentId: previewDeploymentId,
})
.then(() => {
refetchPreviewDeployments();
toast.success("Preview deployment deleted");
})
.catch((error) => {
toast.error(error.message);
});
};
return (
<Card className="bg-background">
@@ -65,7 +81,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
each pull request you create.
</span>
</div>
{data?.previewDeployments?.length === 0 ? (
{!previewDeployments?.length ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
@@ -74,120 +90,131 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
</div>
) : (
<div className="flex flex-col gap-4">
{previewDeployments?.map((previewDeployment) => {
const { deployments, domain } = previewDeployment;
{previewDeployments?.map((deployment) => {
const deploymentUrl = `${deployment.domain?.https ? "https" : "http"}://${deployment.domain?.host}${deployment.domain?.path || "/"}`;
const status = deployment.previewStatus;
return (
<div
key={previewDeployment?.previewDeploymentId}
className="flex flex-col justify-between rounded-lg border p-4 gap-2"
key={deployment.previewDeploymentId}
className="group relative overflow-hidden border rounded-lg transition-colors"
>
<div className="flex justify-between gap-2 max-sm:flex-wrap">
<div className="flex flex-col gap-2">
{deployments?.length === 0 ? (
<div>
<span className="text-sm text-muted-foreground">
No deployments found
</span>
</div>
) : (
<div className="flex items-center gap-2">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{previewDeployment?.pullRequestTitle}
</span>
<StatusTooltip
status={previewDeployment.previewStatus}
className="size-2.5"
/>
</div>
)}
<div className="flex flex-col gap-1">
{previewDeployment?.pullRequestTitle && (
<div className="flex items-center gap-2">
<span className="break-all text-sm text-muted-foreground w-fit">
Title: {previewDeployment?.pullRequestTitle}
</span>
</div>
)}
<div
className={`absolute left-0 top-0 w-1 h-full ${
status === "done"
? "bg-green-500"
: status === "running"
? "bg-yellow-500"
: "bg-red-500"
}`}
/>
{previewDeployment?.pullRequestURL && (
<div className="flex items-center gap-2">
<GithubIcon />
<Link
target="_blank"
href={previewDeployment?.pullRequestURL}
className="break-all text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
>
Pull Request URL
</Link>
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-start gap-3">
<GitPullRequest className="size-5 text-muted-foreground mt-1 flex-shrink-0" />
<div>
<div className="font-medium text-sm">
{deployment.pullRequestTitle}
</div>
<div className="text-sm text-muted-foreground mt-1">
{deployment.branch}
</div>
)}
</div>
<div className="flex flex-col ">
<span>Domain </span>
<div className="flex flex-row items-center gap-4">
<Link
target="_blank"
href={`http://${domain?.host}`}
className="text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
>
{domain?.host}
</Link>
<AddPreviewDomain
previewDeploymentId={
previewDeployment.previewDeploymentId
}
domainId={domain?.domainId}
>
<Button variant="outline" size="sm">
<Pencil className="size-4 text-muted-foreground" />
</Button>
</AddPreviewDomain>
</div>
</div>
<Badge variant="outline" className="gap-2">
<StatusTooltip
status={deployment.previewStatus}
className="size-2"
/>
<DateTooltip date={deployment.createdAt} />
</Badge>
</div>
<div className="flex flex-col sm:items-end gap-2 max-sm:w-full">
{previewDeployment?.createdAt && (
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip
date={previewDeployment?.createdAt}
/>
</div>
)}
<ShowPreviewBuilds
deployments={previewDeployment?.deployments || []}
serverId={data?.serverId || ""}
/>
<div className="pl-8 space-y-3">
<div className="relative flex-grow">
<Input
value={deploymentUrl}
readOnly
className="pr-8 text-sm text-blue-500 hover:text-blue-600 cursor-pointer"
onClick={() =>
window.open(deploymentUrl, "_blank")
}
/>
<ExternalLink className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-400" />
</div>
<ShowModalLogs
appName={previewDeployment.appName}
serverId={data?.serverId || ""}
>
<Button variant="outline">View Logs</Button>
</ShowModalLogs>
<DialogAction
title="Delete Preview"
description="Are you sure you want to delete this preview?"
onClick={() => {
deletePreviewDeployment({
previewDeploymentId:
previewDeployment.previewDeploymentId,
})
.then(() => {
refetchPreviewDeployments();
toast.success("Preview deployment deleted");
})
.catch((error) => {
toast.error(error.message);
});
}}
>
<Button variant="destructive" isLoading={isLoading}>
Delete Preview
<div className="flex gap-2 opacity-80 group-hover:opacity-100 transition-opacity">
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={() =>
window.open(deployment.pullRequestURL, "_blank")
}
>
<GithubIcon className="size-4" />
Pull Request
</Button>
</DialogAction>
<ShowModalLogs
appName={deployment.appName}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
size="sm"
className="gap-2"
>
<FileText className="size-4" />
Logs
</Button>
</ShowModalLogs>
<ShowPreviewBuilds
deployments={deployment.deployments || []}
serverId={data?.serverId || ""}
trigger={
<Button
variant="outline"
size="sm"
className="gap-2"
>
<Layers className="size-4" />
Builds
</Button>
}
/>
<AddPreviewDomain
previewDeploymentId={`${deployment.previewDeploymentId}`}
domainId={deployment.domain?.domainId}
>
<Button
variant="ghost"
size="sm"
className="gap-2"
>
<PenSquare className="size-4" />
</Button>
</AddPreviewDomain>
<DialogAction
title="Delete Preview"
description="Are you sure you want to delete this preview?"
onClick={() =>
handleDeletePreviewDeployment(
deployment.previewDeploymentId,
)
}
>
<Button
variant="ghost"
size="sm"
isLoading={isLoading}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,3 @@
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -20,12 +18,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Secrets } from "@/components/ui/secrets";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
@@ -33,6 +26,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const schema = z.object({
env: z.string(),
@@ -116,7 +117,10 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline">View Settings</Button>
<Button variant="outline">
<Settings2 className="size-4" />
Configure
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl w-full">
<DialogHeader>
@@ -218,21 +222,21 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
name="previewCertificateType"
render={({ field }) => (
<FormItem>
<FormLabel>Certificate</FormLabel>
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
Let's Encrypt
</SelectItem>
</SelectContent>
</Select>
@@ -287,16 +291,6 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
"PORT=3000",
].join("\n")}
/>
{/* <CodeEditor
lineWrapping
language="properties"
wrapperClassName="h-[25rem] font-mono"
placeholder={`NODE_ENV=production
PORT=3000
`}
{...field}
/> */}
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -1,76 +0,0 @@
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";
import { Hammer } from "lucide-react";
import { toast } from "sonner";
interface Props {
applicationId: string;
}
export const RedbuildApplication = ({ applicationId }: Props) => {
const { data } = api.application.one.useQuery(
{
applicationId,
},
{ enabled: !!applicationId },
);
const { mutateAsync } = api.application.redeploy.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="secondary"
isLoading={data?.applicationStatus === "running"}
>
Rebuild
<Hammer className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to rebuild the application?
</AlertDialogTitle>
<AlertDialogDescription>
Is required to deploy at least 1 time in order to reuse the same
code
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
toast.success("Redeploying Application....");
await mutateAsync({
applicationId,
})
.then(async () => {
await utils.application.one.invalidate({
applicationId,
});
})
.catch(() => {
toast.error("Error to rebuild the application");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,65 +0,0 @@
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";
import { CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
interface Props {
applicationId: string;
}
export const StartApplication = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = api.application.start.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary" isLoading={isLoading}>
Start
<CheckCircle2 className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to start the application?
</AlertDialogTitle>
<AlertDialogDescription>
This will start the application
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId,
})
.then(async () => {
await utils.application.one.invalidate({
applicationId,
});
toast.success("Application started succesfully");
})
.catch(() => {
toast.error("Error to start the Application");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,65 +0,0 @@
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";
import { Ban } from "lucide-react";
import { toast } from "sonner";
interface Props {
applicationId: string;
}
export const StopApplication = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = api.application.stop.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Stop
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you absolutely sure to stop the application?
</AlertDialogTitle>
<AlertDialogDescription>
This will stop the application
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId,
})
.then(async () => {
await utils.application.one.invalidate({
applicationId,
});
toast.success("Application stopped succesfully");
})
.catch(() => {
toast.error("Error to stop the Application");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle, SquarePen } from "lucide-react";
import { AlertTriangle, PenBoxIcon, SquarePen } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -76,14 +76,14 @@ export const UpdateApplication = ({ applicationId }: Props) => {
description: formData.description || "",
})
.then(() => {
toast.success("Application updated succesfully");
toast.success("Application updated successfully");
utils.application.one.invalidate({
applicationId: applicationId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the application");
toast.error("Error updating the Application");
})
.finally(() => {});
};
@@ -91,8 +91,12 @@ export const UpdateApplication = ({ applicationId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -81,7 +82,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
});
})
.catch(() => {
toast.error("Error to update the command");
toast.error("Error updating the command");
});
};
@@ -91,7 +92,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
<div>
<CardTitle className="text-xl">Run Command</CardTitle>
<CardDescription>
Append a custom command to the compose file
Override a custom command to the compose file
</CardDescription>
</div>
</CardHeader>
@@ -101,6 +102,12 @@ export const AddCommandCompose = ({ composeId }: Props) => {
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<AlertBlock type="warning">
Modifying the default command may affect deployment stability,
impacting logs and monitoring. Proceed carefully and test
thoroughly. By default, the command starts with{" "}
<strong>docker</strong>.
</AlertBlock>
<div className="flex flex-col gap-4">
<FormField
control={form.control}

View File

@@ -1,142 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { Package } from "lucide-react";
import React from "react";
import { AddVolumes } from "../../application/advanced/volumes/add-volumes";
import { DeleteVolume } from "../../application/advanced/volumes/delete-volume";
import { UpdateVolume } from "../../application/advanced/volumes/update-volume";
interface Props {
composeId: string;
}
export const ShowVolumesCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row justify-between flex-wrap gap-4">
<div>
<CardTitle className="text-xl">Volumes</CardTitle>
<CardDescription>
If you want to persist data in this compose use the following config
to setup the volumes
</CardDescription>
</div>
{data && data?.mounts.length > 0 && (
<AddVolumes
serviceId={composeId}
refetch={refetch}
serviceType="compose"
>
Add Volume
</AddVolumes>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{data?.mounts.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<Package className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No volumes/mounts configured
</span>
<AddVolumes
serviceId={composeId}
refetch={refetch}
serviceType="compose"
>
Add Volume
</AddVolumes>
</div>
) : (
<div className="flex flex-col pt-2 gap-4">
<AlertBlock type="info">
Please remember to click Redeploy after adding, editing, or
deleting a mount to apply the changes.
</AlertBlock>
<div className="flex flex-col gap-6">
{data?.mounts.map((mount) => (
<div key={mount.mountId}>
<div
key={mount.mountId}
className="flex w-full flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-10 border rounded-lg p-4"
>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 flex-col gap-4 sm:gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Type</span>
<span className="text-sm text-muted-foreground">
{mount.type.toUpperCase()}
</span>
</div>
{mount.type === "volume" && (
<div className="flex flex-col gap-1">
<span className="font-medium">Volume Name</span>
<span className="text-sm text-muted-foreground">
{mount.volumeName}
</span>
</div>
)}
{mount.type === "file" && (
<>
<div className="flex flex-col gap-1">
<span className="font-medium">Content</span>
<span className="text-sm text-muted-foreground w-40 truncate">
{mount.content}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">File Path</span>
<span className="text-sm text-muted-foreground">
{mount.filePath}
</span>
</div>
</>
)}
{mount.type === "bind" && (
<div className="flex flex-col gap-1">
<span className="font-medium">Host Path</span>
<span className="text-sm text-muted-foreground">
{mount.hostPath}
</span>
</div>
)}
<div className="flex flex-col gap-1">
<span className="font-medium">Mount Path</span>
<span className="text-sm text-muted-foreground">
{mount.mountPath}
</span>
</div>
</div>
<div className="flex flex-row gap-1">
<UpdateVolume
mountId={mount.mountId}
type={mount.type}
refetch={refetch}
serviceType="compose"
/>
<DeleteVolume mountId={mount.mountId} refetch={refetch} />
</div>
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,141 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const deleteComposeSchema = z.object({
projectName: z.string().min(1, {
message: "Compose name is required",
}),
});
type DeleteCompose = z.infer<typeof deleteComposeSchema>;
interface Props {
composeId: string;
}
export const DeleteCompose = ({ composeId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync, isLoading } = api.compose.delete.useMutation();
const { data } = api.compose.one.useQuery(
{ composeId },
{ enabled: !!composeId },
);
const { push } = useRouter();
const form = useForm<DeleteCompose>({
defaultValues: {
projectName: "",
},
resolver: zodResolver(deleteComposeSchema),
});
const onSubmit = async (formData: DeleteCompose) => {
const expectedName = `${data?.name}/${data?.appName}`;
if (formData.projectName === expectedName) {
await mutateAsync({ composeId })
.then((result) => {
push(`/dashboard/project/${result?.projectId}`);
toast.success("Compose deleted successfully");
setIsOpen(false);
})
.catch(() => {
toast.error("Error deleting the compose");
});
} else {
form.setError("projectName", {
message: `Project name must match "${expectedName}"`,
});
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost" isLoading={isLoading}>
<TrashIcon className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
compose. If you are sure please enter the compose name to delete
this compose.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-delete-compose"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel>
To confirm, type "{data?.name}/{data?.appName}" in the box
below
</FormLabel>{" "}
<FormControl>
<Input
placeholder="Enter compose name to confirm"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
<DialogFooter>
<Button
variant="secondary"
onClick={() => {
setIsOpen(false);
}}
>
Cancel
</Button>
<Button
isLoading={isLoading}
form="hook-form-delete-compose"
type="submit"
variant="destructive"
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,226 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import type { ServiceType } from "@dokploy/server/db/schema";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { Copy, Trash2 } from "lucide-react";
import { useRouter } from "next/router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const deleteComposeSchema = z.object({
projectName: z.string().min(1, {
message: "Compose name is required",
}),
deleteVolumes: z.boolean(),
});
type DeleteCompose = z.infer<typeof deleteComposeSchema>;
interface Props {
id: string;
type: ServiceType | "application";
}
export const DeleteService = ({ id, type }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
application: () =>
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data, refetch } = queryMap[type]
? queryMap[type]()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.postgres.remove.useMutation(),
redis: () => api.redis.remove.useMutation(),
mysql: () => api.mysql.remove.useMutation(),
mariadb: () => api.mariadb.remove.useMutation(),
application: () => api.application.delete.useMutation(),
mongo: () => api.mongo.remove.useMutation(),
compose: () => api.compose.delete.useMutation(),
};
const { mutateAsync, isLoading } = mutationMap[type]
? mutationMap[type]()
: api.mongo.remove.useMutation();
const { push } = useRouter();
const form = useForm<DeleteCompose>({
defaultValues: {
projectName: "",
deleteVolumes: false,
},
resolver: zodResolver(deleteComposeSchema),
});
const onSubmit = async (formData: DeleteCompose) => {
const expectedName = `${data?.name}/${data?.appName}`;
if (formData.projectName === expectedName) {
const { deleteVolumes } = formData;
await mutateAsync({
mongoId: id || "",
postgresId: id || "",
redisId: id || "",
mysqlId: id || "",
mariadbId: id || "",
applicationId: id || "",
composeId: id || "",
deleteVolumes,
})
.then((result) => {
push(`/dashboard/project/${result?.projectId}`);
toast.success("deleted successfully");
setIsOpen(false);
})
.catch(() => {
toast.error("Error deleting the service");
});
} else {
form.setError("projectName", {
message: `Project name must match "${expectedName}"`,
});
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isLoading}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the
service. If you are sure please enter the service name to delete
this service.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-delete-compose"
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="projectName"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<span>
To confirm, type{" "}
<Badge
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
variant="outline"
onClick={() => {
if (data?.name && data?.appName) {
copy(`${data.name}/${data.appName}`);
toast.success("Copied to clipboard. Be careful!");
}
}}
>
{data?.name}/{data?.appName}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel>
<FormControl>
<Input
placeholder="Enter compose name to confirm"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{type === "compose" && (
<FormField
control={form.control}
name="deleteVolumes"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="ml-2">
Delete volumes associated with this compose
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</div>
<DialogFooter>
<Button
variant="secondary"
onClick={() => {
setIsOpen(false);
}}
>
Cancel
</Button>
<Button
isLoading={isLoading}
form="hook-form-delete-compose"
type="submit"
variant="destructive"
>
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -20,6 +20,11 @@ interface Props {
export const CancelQueuesCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.cleanQueues.useMutation();
const { data: isCloud } = api.settings.isCloud.useQuery();
if (isCloud) {
return null;
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>

View File

@@ -47,7 +47,7 @@ export const RefreshTokenCompose = ({ composeId }: Props) => {
toast.success("Refresh Token updated");
})
.catch(() => {
toast.error("Error to update the refresh token");
toast.error("Error updating the refresh token");
});
}}
>

View File

@@ -1,3 +1,5 @@
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -5,23 +7,45 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { TerminalLine } from "../../docker/logs/terminal-line";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
interface Props {
logPath: string | null;
serverId?: string;
open: boolean;
onClose: () => void;
errorMessage?: string;
}
export const ShowDeploymentCompose = ({
logPath,
open,
onClose,
serverId,
errorMessage,
}: Props) => {
const [data, setData] = useState("");
const endOfLogsRef = useRef<HTMLDivElement>(null);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [showExtraLogs, setShowExtraLogs] = useState(false);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
};
useEffect(() => {
if (!open || !logPath) return;
@@ -54,13 +78,36 @@ export const ShowDeploymentCompose = ({
};
}, [logPath, open]);
const scrollToBottom = () => {
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
const logs = parseLogs(data);
let filteredLogsResult = logs;
if (serverId) {
let hideSubsequentLogs = false;
filteredLogsResult = logs.filter((log) => {
if (
log.message.includes(
"===================================EXTRA LOGS============================================",
)
) {
hideSubsequentLogs = true;
return showExtraLogs;
}
return showExtraLogs ? true : !hideSubsequentLogs;
});
}
setFilteredLogs(filteredLogsResult);
}, [data, showExtraLogs]);
useEffect(() => {
scrollToBottom();
}, [data]);
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
const optionalErrors = parseLogs(errorMessage || "");
return (
<Dialog
@@ -78,21 +125,58 @@ export const ShowDeploymentCompose = ({
}
}}
>
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogContent className={"sm:max-w-5xl max-h-screen"}>
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription>
See all the details of this deployment
<DialogDescription className="flex items-center gap-2">
<span>
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
{serverId && (
<div className="flex items-center space-x-2">
<Checkbox
id="show-extra-logs"
checked={showExtraLogs}
onCheckedChange={(checked) =>
setShowExtraLogs(checked as boolean)
}
/>
<label
htmlFor="show-extra-logs"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Show Extra Logs
</label>
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
<code>
<pre className="whitespace-pre-wrap break-words">
{data || "Loading..."}
</pre>
<div ref={endOfLogsRef} />
</code>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine key={index} log={log} noTimestamp />
))
) : (
<>
{optionalErrors.length > 0 ? (
optionalErrors.map((log: LogLine, index: number) => (
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
))
) : (
<div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>

View File

@@ -8,7 +8,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { type RouterOutputs, api } from "@/utils/api";
import { RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueuesCompose } from "./cancel-queues-compose";
@@ -19,7 +19,9 @@ interface Props {
composeId: string;
}
export const ShowDeploymentsCompose = ({ composeId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const { data } = api.compose.one.useQuery({ composeId });
const { data: deployments } = api.deployment.allByCompose.useQuery(
{ composeId },
@@ -100,7 +102,7 @@ export const ShowDeploymentsCompose = ({ composeId }: Props) => {
<Button
onClick={() => {
setActiveLog(deployment.logPath);
setActiveLog(deployment);
}}
>
View
@@ -112,9 +114,10 @@ export const ShowDeploymentsCompose = ({ composeId }: Props) => {
)}
<ShowDeploymentCompose
serverId={data?.serverId || ""}
open={activeLog !== null}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</CardContent>
</Card>

View File

@@ -126,9 +126,7 @@ export const AddDomainCompose = ({
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId
? "Error to update the domain"
: "Error to create the domain",
error: domainId ? "Error updating the domain" : "Error creating the domain",
submit: domainId ? "Update" : "Create",
dialogDescription: domainId
? "In this section you can edit a domain"
@@ -400,21 +398,21 @@ export const AddDomainCompose = ({
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel>
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate" />
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Letsencrypt (Default)
Let's Encrypt
</SelectItem>
</SelectContent>
</Select>

View File

@@ -1,3 +1,4 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -8,9 +9,9 @@ import {
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { ExternalLink, GlobeIcon, PenBoxIcon } from "lucide-react";
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
import Link from "next/link";
import { DeleteDomain } from "../../application/domains/delete-domain";
import { toast } from "sonner";
import { AddDomainCompose } from "./add-domain";
interface Props {
@@ -18,7 +19,7 @@ interface Props {
}
export const ShowDomainsCompose = ({ composeId }: Props) => {
const { data } = api.domain.byComposeId.useQuery(
const { data, refetch } = api.domain.byComposeId.useQuery(
{
composeId,
},
@@ -27,6 +28,9 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
},
);
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
api.domain.delete.useMutation();
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
@@ -93,11 +97,40 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
composeId={composeId}
domainId={item.domainId}
>
<Button variant="ghost">
<PenBoxIcon className="size-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomainCompose>
<DeleteDomain domainId={item.domainId} />
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((data) => {
refetch();
toast.success("Domain deleted successfully");
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
);

View File

@@ -1,167 +0,0 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Toggle } from "@/components/ui/toggle";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const addEnvironmentSchema = z.object({
environment: z.string(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
interface Props {
composeId: string;
}
export const ShowEnvironmentCompose = ({ composeId }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const { mutateAsync, isLoading } = api.compose.update.useMutation();
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const form = useForm<EnvironmentSchema>({
defaultValues: {
environment: "",
},
resolver: zodResolver(addEnvironmentSchema),
});
useEffect(() => {
if (data) {
form.reset({
environment: data.env || "",
});
}
}, [form.reset, data, form]);
const onSubmit = async (data: EnvironmentSchema) => {
mutateAsync({
env: data.environment,
composeId,
})
.then(async () => {
toast.success("Environments Added");
await refetch();
})
.catch(() => {
toast.error("Error to add environment");
});
};
useEffect(() => {
if (isEnvVisible) {
if (data?.env) {
const maskedLines = data.env
.split("\n")
.map((line) => "*".repeat(line.length))
.join("\n");
form.reset({
environment: maskedLines,
});
} else {
form.reset({
environment: "",
});
}
} else {
form.reset({
environment: data?.env || "",
});
}
}, [form.reset, data, form, isEnvVisible]);
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
<CardHeader className="flex flex-row w-full items-center justify-between">
<div>
<CardTitle className="text-xl">Environment Settings</CardTitle>
<CardDescription>
You can add environment variables to your resource.
</CardDescription>
</div>
<Toggle
aria-label="Toggle bold"
pressed={isEnvVisible}
onPressedChange={setIsEnvVisible}
>
{isEnvVisible ? (
<EyeOffIcon className="h-4 w-4 text-muted-foreground" />
) : (
<EyeIcon className="h-4 w-4 text-muted-foreground" />
)}
</Toggle>
</CardHeader>
<CardContent>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="w-full space-y-4"
>
<FormField
control={form.control}
name="environment"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<CodeEditor
language="properties"
disabled={isEnvVisible}
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex flex-row justify-end">
<Button
disabled={isEnvVisible}
isLoading={isLoading}
className="w-fit"
type="submit"
>
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
);
};

View File

@@ -1,28 +1,17 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { CheckCircle2, ExternalLink, Globe, Terminal } from "lucide-react";
import Link from "next/link";
import { Ban, CheckCircle2, Hammer, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { toast } from "sonner";
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
import { StartCompose } from "../start-compose";
import { DeployCompose } from "./deploy-compose";
import { RedbuildCompose } from "./rebuild-compose";
import { StopCompose } from "./stop-compose";
interface Props {
composeId: string;
}
export const ComposeActions = ({ composeId }: Props) => {
const router = useRouter();
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
@@ -30,33 +19,109 @@ export const ComposeActions = ({ composeId }: Props) => {
{ enabled: !!composeId },
);
const { mutateAsync: update } = api.compose.update.useMutation();
const extractDomains = (env: string) => {
const lines = env.split("\n");
const hostLines = lines.filter((line) => {
const [key, value] = line.split("=");
return key?.trim().endsWith("_HOST");
});
const hosts = hostLines.map((line) => {
const [key, value] = line.split("=");
return value ? value.trim() : "";
});
return hosts;
};
const domains = extractDomains(data?.env || "");
const { mutateAsync: deploy } = api.compose.deploy.useMutation();
const { mutateAsync: redeploy } = api.compose.redeploy.useMutation();
const { mutateAsync: start, isLoading: isStarting } =
api.compose.start.useMutation();
const { mutateAsync: stop, isLoading: isStopping } =
api.compose.stop.useMutation();
return (
<div className="flex flex-row gap-4 w-full flex-wrap ">
<DeployCompose composeId={composeId} />
<RedbuildCompose composeId={composeId} />
<DialogAction
title="Deploy Compose"
description="Are you sure you want to deploy this compose?"
type="default"
onClick={async () => {
await deploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose deployed successfully");
refetch();
router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
);
})
.catch(() => {
toast.error("Error deploying compose");
});
}}
>
<Button variant="default" isLoading={data?.composeStatus === "running"}>
Deploy
</Button>
</DialogAction>
<DialogAction
title="Rebuild Compose"
description="Are you sure you want to rebuild this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding compose");
});
}}
>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
>
Rebuild
<Hammer className="size-4" />
</Button>
</DialogAction>
{data?.composeType === "docker-compose" &&
data?.composeStatus === "idle" ? (
<StartCompose composeId={composeId} />
<DialogAction
title="Start Compose"
description="Are you sure you want to start this compose?"
type="default"
onClick={async () => {
await start({
composeId: composeId,
})
.then(() => {
toast.success("Compose started successfully");
refetch();
})
.catch(() => {
toast.error("Error starting compose");
});
}}
>
<Button variant="secondary" isLoading={isStarting}>
Start
<CheckCircle2 className="size-4" />
</Button>
</DialogAction>
) : (
<StopCompose composeId={composeId} />
<DialogAction
title="Stop Compose"
description="Are you sure you want to stop this compose?"
onClick={async () => {
await stop({
composeId: composeId,
})
.then(() => {
toast.success("Compose stopped successfully");
refetch();
})
.catch(() => {
toast.error("Error stopping compose");
});
}}
>
<Button variant="destructive" isLoading={isStopping}>
Stop
<Ban className="size-4" />
</Button>
</DialogAction>
)}
<DockerTerminalModal
@@ -83,47 +148,12 @@ export const ComposeActions = ({ composeId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to update Auto Deploy");
toast.error("Error updating Auto Deploy");
});
}}
className="flex flex-row gap-2 items-center"
/>
</div>
{domains.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Domains
<Globe className="text-xs size-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Domains detected</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{domains.map((host, index) => {
const url =
host.startsWith("http://") || host.startsWith("https://")
? host
: `http://${host}`;
return (
<DropdownMenuItem
key={`domain-${index}`}
className="cursor-pointer"
asChild
>
<Link href={url} target="_blank">
{host}
<ExternalLink className="ml-2 text-xs text-muted-foreground" />
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
};

View File

@@ -77,7 +77,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
});
})
.catch((e) => {
toast.error("Error to update the compose config");
toast.error("Error updating the Compose config");
});
};
return (

View File

@@ -1,64 +0,0 @@
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";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const DeployCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync: deploy } = api.compose.deploy.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button isLoading={data?.composeStatus === "running"}>Deploy</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will deploy the compose
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
toast.success("Deploying Compose....");
await refetch();
await deploy({
composeId,
}).catch(() => {
toast.error("Error to deploy Compose");
});
await refetch();
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -139,7 +139,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to save the Bitbucket provider");
toast.error("Error saving the Bitbucket provider");
});
};
@@ -237,7 +237,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -247,7 +247,12 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -83,7 +83,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to save the Git provider");
toast.error("Error saving the Git provider");
});
};

View File

@@ -133,7 +133,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to save the github provider");
toast.error("Error saving the Github provider");
});
};
@@ -228,7 +228,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -238,7 +238,12 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -146,7 +146,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
await refetch();
})
.catch(() => {
toast.error("Error to save the gitlab provider");
toast.error("Error saving the Gitlab provider");
});
};
@@ -250,7 +250,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
{repositories?.map((repo) => {
return (
<CommandItem
value={repo.url}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -262,7 +262,12 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
{repo.name}
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",

View File

@@ -90,7 +90,7 @@ export const RandomizeCompose = ({ composeId }: Props) => {
toast.success("Compose updated");
})
.catch(() => {
toast.error("Error to randomize the compose");
toast.error("Error randomizing the compose");
});
};
@@ -105,7 +105,7 @@ export const RandomizeCompose = ({ composeId }: Props) => {
toast.success("Compose randomized");
})
.catch(() => {
toast.error("Error to randomize the compose");
toast.error("Error randomizing the compose");
});
};

View File

@@ -1,75 +0,0 @@
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";
import { Hammer } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const RedbuildCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync } = api.compose.redeploy.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="secondary"
isLoading={data?.composeStatus === "running"}
>
Rebuild
<Hammer className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to rebuild the compose?
</AlertDialogTitle>
<AlertDialogDescription>
Is required to deploy at least 1 time in order to reuse the same
code
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
toast.success("Redeploying Compose....");
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
})
.catch(() => {
toast.error("Error to rebuild the compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -73,7 +73,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
toast.success("Fetched source type");
})
.catch((err) => {
toast.error("Error to fetch source type", {
toast.error("Error fetching source type", {
description: err.message,
});
});

View File

@@ -1,69 +0,0 @@
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";
import { Ban } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const StopCompose = ({ composeId }: Props) => {
const { data } = api.compose.one.useQuery(
{
composeId,
},
{ enabled: !!composeId },
);
const { mutateAsync, isLoading } = api.compose.stop.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Stop
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure to stop the compose?</AlertDialogTitle>
<AlertDialogDescription>
This will stop the compose services
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose stopped succesfully");
})
.catch(() => {
toast.error("Error to stop the compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,165 @@
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
export const DockerLogs = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
serverId?: string;
}
badgeStateColor;
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();
const { data: services, isLoading: servicesLoading } =
api.docker.getStackContainersByAppName.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName && option === "swarm",
},
);
const { data: containers, isLoading: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType: "stack",
serverId,
},
{
enabled: !!appName && option === "native",
},
);
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
option === "native" ? containers?.length : services?.length;
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Logs</CardTitle>
<CardDescription>
Watch the logs of the application in real time
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
{option === "native" ? "Native" : "Swarm"}
</span>
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setOption(checked ? "native" : "swarm");
}}
/>
</div>
</div>
<Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger>
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<SelectValue placeholder="Select a container" />
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{option === "native" ? (
<div>
{containers?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}){" "}
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
</div>
) : (
<>
{services?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}@{container.node}
)
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<DockerLogs
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
/>
</CardContent>
</Card>
);
};

View File

@@ -1,3 +1,5 @@
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
@@ -87,7 +89,10 @@ export const ShowDockerLogsCompose = ({
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}) {container.state}
{container.name} ({container.containerId}){" "}
<Badge variant={badgeStateColor(container.state)}>
{container.state}
</Badge>
</SelectItem>
))}
<SelectLabel>Containers ({data?.length})</SelectLabel>
@@ -96,8 +101,8 @@ export const ShowDockerLogsCompose = ({
</Select>
<DockerLogs
serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"}
runType="native"
/>
</CardContent>
</Card>

View File

@@ -1,65 +0,0 @@
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";
import { CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const StartCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.start.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary" isLoading={isLoading}>
Start
<CheckCircle2 className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to start the compose?
</AlertDialogTitle>
<AlertDialogDescription>
This will start the compose
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose started succesfully");
})
.catch(() => {
toast.error("Error to start the Compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,65 +0,0 @@
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";
import { Ban } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const StopCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.stop.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" isLoading={isLoading}>
Stop
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you absolutely sure to stop the compose?
</AlertDialogTitle>
<AlertDialogDescription>
This will stop the compose
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(async () => {
await utils.compose.one.invalidate({
composeId,
});
toast.success("Compose stopped succesfully");
})
.catch(() => {
toast.error("Error to stop the Compose");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -21,7 +21,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { SquarePen } from "lucide-react";
import { PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -76,14 +76,14 @@ export const UpdateCompose = ({ composeId }: Props) => {
description: formData.description || "",
})
.then(() => {
toast.success("Compose updated succesfully");
toast.success("Compose updated successfully");
utils.compose.one.invalidate({
composeId: composeId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error to update the Compose");
toast.error("Error updating the Compose");
})
.finally(() => {});
};
@@ -91,8 +91,12 @@ export const UpdateCompose = ({ composeId }: Props) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="ghost">
<SquarePen className="size-4 text-muted-foreground" />
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 "
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">

View File

@@ -125,7 +125,7 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
refetch();
})
.catch(() => {
toast.error("Error to create a backup");
toast.error("Error creating a backup");
});
};
return (

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