mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-27 07:22:17 +00:00
Merge branch 'canary' into fix/environment-access-fallback
This commit is contained in:
@@ -24,14 +24,14 @@ jobs:
|
|||||||
- name: Install Nixpacks
|
- name: Install Nixpacks
|
||||||
if: matrix.job == 'test'
|
if: matrix.job == 'test'
|
||||||
run: |
|
run: |
|
||||||
export NIXPACKS_VERSION=1.39.0
|
export NIXPACKS_VERSION=1.41.0
|
||||||
curl -sSL https://nixpacks.com/install.sh | bash
|
curl -sSL https://nixpacks.com/install.sh | bash
|
||||||
echo "Nixpacks installed $NIXPACKS_VERSION"
|
echo "Nixpacks installed $NIXPACKS_VERSION"
|
||||||
|
|
||||||
- name: Install Railpack
|
- name: Install Railpack
|
||||||
if: matrix.job == 'test'
|
if: matrix.job == 'test'
|
||||||
run: |
|
run: |
|
||||||
export RAILPACK_VERSION=0.15.0
|
export RAILPACK_VERSION=0.15.4
|
||||||
curl -sSL https://railpack.com/install.sh | bash
|
curl -sSL https://railpack.com/install.sh | bash
|
||||||
echo "Railpack installed $RAILPACK_VERSION"
|
echo "Railpack installed $RAILPACK_VERSION"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -148,7 +148,7 @@ curl -sSL https://railpack.com/install.sh | sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Buildpacks
|
# Install Buildpacks
|
||||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pull Request
|
## Pull Request
|
||||||
|
|||||||
+3
-3
@@ -51,18 +51,18 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --ver
|
|||||||
# Install Nixpacks and tsx
|
# Install Nixpacks and tsx
|
||||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||||
|
|
||||||
ARG NIXPACKS_VERSION=1.39.0
|
ARG NIXPACKS_VERSION=1.41.0
|
||||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||||
&& chmod +x install.sh \
|
&& chmod +x install.sh \
|
||||||
&& ./install.sh \
|
&& ./install.sh \
|
||||||
&& pnpm install -g tsx
|
&& pnpm install -g tsx
|
||||||
|
|
||||||
# Install Railpack
|
# Install Railpack
|
||||||
ARG RAILPACK_VERSION=0.2.2
|
ARG RAILPACK_VERSION=0.15.4
|
||||||
RUN curl -sSL https://railpack.com/install.sh | bash
|
RUN curl -sSL https://railpack.com/install.sh | bash
|
||||||
|
|
||||||
# Install buildpacks
|
# Install buildpacks
|
||||||
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
|
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD [ "pnpm", "start" ]
|
CMD [ "pnpm", "start" ]
|
||||||
|
|||||||
+1
-1
@@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash
|
|||||||
RUN pnpm install -g tsx
|
RUN pnpm install -g tsx
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD [ "pnpm", "start" ]
|
CMD [ "pnpm", "start" ]
|
||||||
|
|||||||
+1
-1
@@ -35,4 +35,4 @@ COPY --from=build /prod/schedules/dist ./dist
|
|||||||
COPY --from=build /prod/schedules/package.json ./package.json
|
COPY --from=build /prod/schedules/package.json ./package.json
|
||||||
COPY --from=build /prod/schedules/node_modules ./node_modules
|
COPY --from=build /prod/schedules/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||||
|
|||||||
+1
-1
@@ -35,4 +35,4 @@ COPY --from=build /prod/api/dist ./dist
|
|||||||
COPY --from=build /prod/api/package.json ./package.json
|
COPY --from=build /prod/api/package.json ./package.json
|
||||||
COPY --from=build /prod/api/node_modules ./node_modules
|
COPY --from=build /prod/api/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||||
|
|||||||
@@ -68,53 +68,21 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
|
|
||||||
[Github Sponsors](https://github.com/sponsors/Siumauricio)
|
[Github Sponsors](https://github.com/sponsors/Siumauricio)
|
||||||
|
|
||||||
<!-- Hero Sponsors 🎖 -->
|
## Sponsors
|
||||||
|
|
||||||
<!-- Add Hero Sponsors here -->
|
| Sponsor | Logo | Supporter Level |
|
||||||
|
|---------|:----:|----------------|
|
||||||
### Hero Sponsors 🎖
|
| [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) | <img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="200"/> | 🎖 Hero Sponsor |
|
||||||
|
| [LX Aer](https://www.lxaer.com/?ref=dokploy) | <img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/> | 🎖 Hero Sponsor |
|
||||||
<div>
|
| [LinkDR](https://linkdr.com/?ref=dokploy) | <img src="https://dokploy.com/linkdr-logo.svg" alt="LinkDR" width="100"/> | 🎖 Hero Sponsor |
|
||||||
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
|
| [LambdaTest](https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor) | <img src="https://www.lambdatest.com/blue-logo.png" alt="LambdaTest" width="200"/> | 🎖 Hero Sponsor |
|
||||||
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
|
| [Awesome Tools](https://awesome.tools/) | <img src=".github/sponsors/awesome.png" alt="Awesome Tools" width="100"/> | 🎖 Hero Sponsor |
|
||||||
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
|
| [Supafort](https://supafort.com/?ref=dokploy) | <img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="200"/> | 🥇 Premium Supporter |
|
||||||
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
|
| [Agentdock](https://agentdock.ai/?ref=dokploy) | <img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/> | 🥇 Premium Supporter |
|
||||||
</a>
|
| [AmericanCloud](https://americancloud.com/?ref=dokploy) | <img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="200"/> | 🥈 Elite Contributor |
|
||||||
<a href="https://awesome.tools/" target="_blank">
|
| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | <img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/> | 🥈 Elite Contributor |
|
||||||
<img src=".github/sponsors/awesome.png" width="200" height="150" />
|
| [Cloudblast](https://cloudblast.io/?ref=dokploy) | <img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" alt="Cloudblast.io" width="150"/> | 🥉 Supporting Member |
|
||||||
</a>
|
| [Synexa](https://synexa.ai/?ref=dokploy) | <img src=".github/sponsors/synexa.png" alt="Synexa" width="100"/> | 🥉 Supporting Member |
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Premium Supporters 🥇 -->
|
|
||||||
|
|
||||||
<!-- Add Premium Supporters here -->
|
|
||||||
|
|
||||||
### Premium Supporters 🥇
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
|
|
||||||
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Elite Contributors 🥈 -->
|
|
||||||
|
|
||||||
<!-- Add Elite Contributors here -->
|
|
||||||
|
|
||||||
### Elite Contributors 🥈
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
|
|
||||||
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Supporting Members 🥉
|
|
||||||
|
|
||||||
<div>
|
|
||||||
|
|
||||||
<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://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Community Backers 🤝
|
### Community Backers 🤝
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.14.3",
|
||||||
"@hono/zod-validator": "0.3.0",
|
"@hono/zod-validator": "0.3.0",
|
||||||
"@nerimity/mimiqueue": "1.2.3",
|
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"hono": "^4.7.10",
|
"hono": "^4.7.10",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
|||||||
titleLog: z.string().optional(),
|
titleLog: z.string().optional(),
|
||||||
descriptionLog: z.string().optional(),
|
descriptionLog: z.string().optional(),
|
||||||
server: z.boolean().optional(),
|
server: z.boolean().optional(),
|
||||||
type: z.enum(["deploy"]),
|
type: z.enum(["deploy", "redeploy"]),
|
||||||
applicationType: z.literal("application-preview"),
|
applicationType: z.literal("application-preview"),
|
||||||
serverId: z.string().min(1),
|
serverId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
deployPreviewApplication,
|
deployPreviewApplication,
|
||||||
rebuildApplication,
|
rebuildApplication,
|
||||||
rebuildCompose,
|
rebuildCompose,
|
||||||
|
rebuildPreviewApplication,
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -54,7 +55,14 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
previewStatus: "running",
|
previewStatus: "running",
|
||||||
});
|
});
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "deploy") {
|
if (job.type === "redeploy") {
|
||||||
|
await rebuildPreviewApplication({
|
||||||
|
applicationId: job.applicationId,
|
||||||
|
titleLog: job.titleLog || "Rebuild Preview Deployment",
|
||||||
|
descriptionLog: job.descriptionLog || "",
|
||||||
|
previewDeploymentId: job.previewDeploymentId,
|
||||||
|
});
|
||||||
|
} else if (job.type === "deploy") {
|
||||||
await deployPreviewApplication({
|
await deployPreviewApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Preview Deployment",
|
titleLog: job.titleLog || "Preview Deployment",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ if (typeof window === "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
railpackVersion: "0.2.2",
|
railpackVersion: "0.15.4",
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
createEnvFile: true,
|
createEnvFile: true,
|
||||||
|
|||||||
+184
@@ -0,0 +1,184 @@
|
|||||||
|
import { getEnviromentVariablesObject } from "@dokploy/server/index";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const projectEnv = `
|
||||||
|
ENVIRONMENT=staging
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
|
||||||
|
PORT=3000
|
||||||
|
`;
|
||||||
|
|
||||||
|
const environmentEnv = `
|
||||||
|
NODE_ENV=development
|
||||||
|
API_URL=https://api.dev.example.com
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
DATABASE_NAME=dev_database
|
||||||
|
SECRET_KEY=env-secret-123
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
|
||||||
|
it("resolves environment variables correctly for Stack compose", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
FOO=\${{environment.NODE_ENV}}
|
||||||
|
BAR=\${{environment.API_URL}}
|
||||||
|
BAZ=test
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
FOO: "development",
|
||||||
|
BAR: "https://api.dev.example.com",
|
||||||
|
BAZ: "test",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves both project and environment variables for Stack compose", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
ENVIRONMENT=\${{project.ENVIRONMENT}}
|
||||||
|
NODE_ENV=\${{environment.NODE_ENV}}
|
||||||
|
API_URL=\${{environment.API_URL}}
|
||||||
|
DATABASE_URL=\${{project.DATABASE_URL}}
|
||||||
|
SERVICE_PORT=4000
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ENVIRONMENT: "staging",
|
||||||
|
NODE_ENV: "development",
|
||||||
|
API_URL: "https://api.dev.example.com",
|
||||||
|
DATABASE_URL: "postgres://postgres:postgres@localhost:5432/project_db",
|
||||||
|
SERVICE_PORT: "4000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple environment references in single value for Stack compose", () => {
|
||||||
|
const multiRefEnv = `
|
||||||
|
HOST=localhost
|
||||||
|
PORT=5432
|
||||||
|
USERNAME=postgres
|
||||||
|
PASSWORD=secret123
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serviceEnv = `
|
||||||
|
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error for undefined environment variables in Stack compose", () => {
|
||||||
|
const serviceWithUndefined = `
|
||||||
|
UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||||
|
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows service variables to override environment variables in Stack compose", () => {
|
||||||
|
const serviceOverrideEnv = `
|
||||||
|
NODE_ENV=production
|
||||||
|
API_URL=\${{environment.API_URL}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceOverrideEnv,
|
||||||
|
"",
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
NODE_ENV: "production",
|
||||||
|
API_URL: "https://api.dev.example.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves complex references with project, environment, and service variables for Stack compose", () => {
|
||||||
|
const complexServiceEnv = `
|
||||||
|
FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}}
|
||||||
|
API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api
|
||||||
|
SERVICE_NAME=my-service
|
||||||
|
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
complexServiceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
FULL_DATABASE_URL:
|
||||||
|
"postgres://postgres:postgres@localhost:5432/project_db/dev_database",
|
||||||
|
API_ENDPOINT: "https://api.dev.example.com/staging/api",
|
||||||
|
SERVICE_NAME: "my-service",
|
||||||
|
COMPLEX_VAR: "my-service-development-staging",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintains precedence: service > environment > project in Stack compose", () => {
|
||||||
|
const conflictingProjectEnv = `
|
||||||
|
NODE_ENV=production-project
|
||||||
|
API_URL=https://project.api.com
|
||||||
|
DATABASE_NAME=project_db
|
||||||
|
`;
|
||||||
|
|
||||||
|
const conflictingEnvironmentEnv = `
|
||||||
|
NODE_ENV=development-environment
|
||||||
|
API_URL=https://environment.api.com
|
||||||
|
DATABASE_NAME=env_db
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serviceWithConflicts = `
|
||||||
|
NODE_ENV=service-override
|
||||||
|
PROJECT_ENV=\${{project.NODE_ENV}}
|
||||||
|
ENV_VAR=\${{environment.API_URL}}
|
||||||
|
DB_NAME=\${{environment.DATABASE_NAME}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceWithConflicts,
|
||||||
|
conflictingProjectEnv,
|
||||||
|
conflictingEnvironmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
NODE_ENV: "service-override",
|
||||||
|
PROJECT_ENV: "production-project",
|
||||||
|
ENV_VAR: "https://environment.api.com",
|
||||||
|
DB_NAME: "env_db",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty environment variables in Stack compose", () => {
|
||||||
|
const serviceWithEmpty = `
|
||||||
|
SERVICE_VAR=test
|
||||||
|
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceWithEmpty,
|
||||||
|
projectEnv,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
SERVICE_VAR: "test",
|
||||||
|
PROJECT_VAR: "staging",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { createRouterConfig } from "@dokploy/server";
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
railpackVersion: "0.2.2",
|
railpackVersion: "0.15.4",
|
||||||
rollbackActive: false,
|
rollbackActive: false,
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
|
|||||||
+147
-890
File diff suppressed because it is too large
Load Diff
+154
@@ -0,0 +1,154 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const endpointSpecFormSchema = z.object({
|
||||||
|
Mode: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface EndpointSpecFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(endpointSpecFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Mode: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.endpointSpecSwarm) {
|
||||||
|
const es = data.endpointSpecSwarm;
|
||||||
|
form.reset({
|
||||||
|
Mode: es.Mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof endpointSpecFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue =
|
||||||
|
formData.Mode !== undefined &&
|
||||||
|
formData.Mode !== null &&
|
||||||
|
formData.Mode !== "";
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
endpointSpecSwarm: hasAnyValue ? formData : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Endpoint spec updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating endpoint spec");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Mode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Mode</FormLabel>
|
||||||
|
<FormDescription>Endpoint mode (vip or dnsrr)</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select endpoint mode" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="vip">VIP (Virtual IP)</SelectItem>
|
||||||
|
<SelectItem value="dnsrr">DNS Round Robin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Mode: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Endpoint Spec
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const healthCheckFormSchema = z.object({
|
||||||
|
Test: z.array(z.string()).optional(),
|
||||||
|
Interval: z.coerce.number().optional(),
|
||||||
|
Timeout: z.coerce.number().optional(),
|
||||||
|
StartPeriod: z.coerce.number().optional(),
|
||||||
|
Retries: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface HealthCheckFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [testCommands, setTestCommands] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(healthCheckFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Test: [],
|
||||||
|
Interval: undefined,
|
||||||
|
Timeout: undefined,
|
||||||
|
StartPeriod: undefined,
|
||||||
|
Retries: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.healthCheckSwarm) {
|
||||||
|
const hc = data.healthCheckSwarm;
|
||||||
|
form.reset({
|
||||||
|
Test: hc.Test || [],
|
||||||
|
Interval: hc.Interval,
|
||||||
|
Timeout: hc.Timeout,
|
||||||
|
StartPeriod: hc.StartPeriod,
|
||||||
|
Retries: hc.Retries,
|
||||||
|
});
|
||||||
|
setTestCommands(hc.Test || []);
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof healthCheckFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue =
|
||||||
|
(formData.Test && formData.Test.length > 0) ||
|
||||||
|
formData.Interval !== undefined ||
|
||||||
|
formData.Timeout !== undefined ||
|
||||||
|
formData.StartPeriod !== undefined ||
|
||||||
|
formData.Retries !== undefined;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
healthCheckSwarm: hasAnyValue ? formData : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Health check updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating health check");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTestCommand = () => {
|
||||||
|
setTestCommands([...testCommands, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTestCommand = (index: number, value: string) => {
|
||||||
|
const newCommands = [...testCommands];
|
||||||
|
newCommands[index] = value;
|
||||||
|
setTestCommands(newCommands);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTestCommand = (index: number) => {
|
||||||
|
setTestCommands(testCommands.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Test Commands</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Command to run for health check (e.g., ["CMD-SHELL", "curl -f
|
||||||
|
http://localhost:3000/health"])
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{testCommands.map((cmd, index) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={cmd}
|
||||||
|
onChange={(e) => updateTestCommand(index, e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
index === 0
|
||||||
|
? "CMD-SHELL"
|
||||||
|
: "curl -f http://localhost:3000/health"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeTestCommand(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addTestCommand}
|
||||||
|
>
|
||||||
|
Add Command
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Interval"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Interval (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Timeout"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Timeout (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum time to wait for health check response
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="StartPeriod"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Start Period (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Initial grace period before health checks begin
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Retries"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Retries</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Number of consecutive failures needed to consider container
|
||||||
|
unhealthy
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="3" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Test: [],
|
||||||
|
Interval: undefined,
|
||||||
|
Timeout: undefined,
|
||||||
|
StartPeriod: undefined,
|
||||||
|
Retries: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Health Check
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { HealthCheckForm } from "./health-check-form";
|
||||||
|
export { RestartPolicyForm } from "./restart-policy-form";
|
||||||
|
export { PlacementForm } from "./placement-form";
|
||||||
|
export { UpdateConfigForm } from "./update-config-form";
|
||||||
|
export { RollbackConfigForm } from "./rollback-config-form";
|
||||||
|
export { ModeForm } from "./mode-form";
|
||||||
|
export { LabelsForm } from "./labels-form";
|
||||||
|
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||||
|
export { EndpointSpecForm } from "./endpoint-spec-form";
|
||||||
|
export { filterEmptyValues, hasValues } from "./utils";
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const labelsFormSchema = z.object({
|
||||||
|
labels: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
key: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LabelsFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(labelsFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "labels",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.labelsSwarm && typeof data.labelsSwarm === "object") {
|
||||||
|
const labelEntries = Object.entries(data.labelsSwarm).map(
|
||||||
|
([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: value as string,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
form.reset({ labels: labelEntries });
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof labelsFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const labelsObject =
|
||||||
|
formData.labels?.reduce(
|
||||||
|
(acc, { key, value }) => {
|
||||||
|
if (key && value) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
) || {};
|
||||||
|
|
||||||
|
// If no labels, send null to clear the database
|
||||||
|
const labelsToSend =
|
||||||
|
Object.keys(labelsObject).length > 0 ? labelsObject : null;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
labelsSwarm: labelsToSend,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Labels updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating labels");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Labels</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Add key-value labels to your service
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex gap-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`labels.${index}.key`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="com.example.app.name" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`labels.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="my-app" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => append({ key: "", value: "" })}
|
||||||
|
>
|
||||||
|
Add Label
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({ labels: [] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Labels
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+195
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface ModeFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
defaultValues: {
|
||||||
|
type: undefined,
|
||||||
|
Replicas: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const modeType = form.watch("type");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.modeSwarm) {
|
||||||
|
const mode = data.modeSwarm;
|
||||||
|
if (mode.Replicated) {
|
||||||
|
form.reset({
|
||||||
|
type: "Replicated",
|
||||||
|
Replicas: mode.Replicated.Replicas,
|
||||||
|
});
|
||||||
|
} else if (mode.Global) {
|
||||||
|
form.reset({
|
||||||
|
type: "Global",
|
||||||
|
Replicas: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: any) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// If no type is selected, send null to clear the database
|
||||||
|
if (!formData.type) {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
modeSwarm: null,
|
||||||
|
});
|
||||||
|
toast.success("Mode updated successfully");
|
||||||
|
refetch();
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeData =
|
||||||
|
formData.type === "Replicated"
|
||||||
|
? { Replicated: { Replicas: formData.Replicas } }
|
||||||
|
: { Global: {} };
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
modeSwarm: modeData,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Mode updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating mode");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="type"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Mode Type</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Choose between replicated or global service mode
|
||||||
|
</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select mode type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Replicated">Replicated</SelectItem>
|
||||||
|
<SelectItem value="Global">Global</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{modeType === "Replicated" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Replicas"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Replicas</FormLabel>
|
||||||
|
<FormDescription>Number of replicas to run</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
type: undefined,
|
||||||
|
Replicas: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Mode
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+342
@@ -0,0 +1,342 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const PreferenceSchema = z.object({
|
||||||
|
Spread: z.object({
|
||||||
|
SpreadDescriptor: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PlatformSchema = z.object({
|
||||||
|
Architecture: z.string(),
|
||||||
|
OS: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const placementFormSchema = z.object({
|
||||||
|
Constraints: z.array(z.string()).optional(),
|
||||||
|
Preferences: z.array(PreferenceSchema).optional(),
|
||||||
|
MaxReplicas: z.coerce.number().optional(),
|
||||||
|
Platforms: z.array(PlatformSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PlacementFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(placementFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Constraints: [],
|
||||||
|
Preferences: [],
|
||||||
|
MaxReplicas: undefined,
|
||||||
|
Platforms: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const constraints = form.watch("Constraints") || [];
|
||||||
|
const preferences = form.watch("Preferences") || [];
|
||||||
|
const platforms = form.watch("Platforms") || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.placementSwarm) {
|
||||||
|
const placement = data.placementSwarm;
|
||||||
|
form.reset({
|
||||||
|
Constraints: placement.Constraints || [],
|
||||||
|
Preferences:
|
||||||
|
placement.Preferences?.map((p: any) => ({
|
||||||
|
SpreadDescriptor: p.Spread?.SpreadDescriptor || "",
|
||||||
|
})) || [],
|
||||||
|
MaxReplicas: placement.MaxReplicas,
|
||||||
|
Platforms: placement.Platforms || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof placementFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue =
|
||||||
|
(formData.Constraints && formData.Constraints.length > 0) ||
|
||||||
|
(formData.Preferences && formData.Preferences.length > 0) ||
|
||||||
|
(formData.Platforms && formData.Platforms.length > 0) ||
|
||||||
|
formData.MaxReplicas !== undefined;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
placementSwarm: hasAnyValue ? formData : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Placement updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating placement");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addConstraint = () => {
|
||||||
|
form.setValue("Constraints", [...constraints, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConstraint = (index: number, value: string) => {
|
||||||
|
const newConstraints = [...constraints];
|
||||||
|
newConstraints[index] = value;
|
||||||
|
form.setValue("Constraints", newConstraints);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeConstraint = (index: number) => {
|
||||||
|
form.setValue(
|
||||||
|
"Constraints",
|
||||||
|
constraints.filter((_: string, i: number) => i !== index),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPreference = () => {
|
||||||
|
form.setValue("Preferences", [...preferences, { SpreadDescriptor: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePreference = (index: number, value: string) => {
|
||||||
|
const newPreferences = [...preferences];
|
||||||
|
if (newPreferences[index]) {
|
||||||
|
newPreferences[index].SpreadDescriptor = value;
|
||||||
|
form.setValue("Preferences", newPreferences);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePreference = (index: number) => {
|
||||||
|
form.setValue(
|
||||||
|
"Preferences",
|
||||||
|
preferences.filter((_: any, i: number) => i !== index),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPlatform = () => {
|
||||||
|
form.setValue("Platforms", [...platforms, { Architecture: "", OS: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePlatform = (
|
||||||
|
index: number,
|
||||||
|
field: "Architecture" | "OS",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const newPlatforms = [...platforms];
|
||||||
|
if (newPlatforms[index]) {
|
||||||
|
newPlatforms[index][field] = value;
|
||||||
|
form.setValue("Platforms", newPlatforms);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePlatform = (index: number) => {
|
||||||
|
form.setValue(
|
||||||
|
"Platforms",
|
||||||
|
platforms.filter((_: any, i: number) => i !== index),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Constraints</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Placement constraints (e.g., "node.role==manager")
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{constraints.map((constraint: string, index: number) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={constraint}
|
||||||
|
onChange={(e) => updateConstraint(index, e.target.value)}
|
||||||
|
placeholder="node.role==manager"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeConstraint(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addConstraint}
|
||||||
|
>
|
||||||
|
Add Constraint
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel>Preferences</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Spread preferences for task distribution (e.g.,
|
||||||
|
"node.labels.region")
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{preferences.map((pref: any, index: number) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={pref.SpreadDescriptor}
|
||||||
|
onChange={(e) => updatePreference(index, e.target.value)}
|
||||||
|
placeholder="node.labels.region"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removePreference(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addPreference}
|
||||||
|
>
|
||||||
|
Add Preference
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxReplicas"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Replicas</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum number of replicas per node
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel>Platforms</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Target platforms for task scheduling
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{platforms.map((platform: any, index: number) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={platform.Architecture}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePlatform(index, "Architecture", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="amd64"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={platform.OS}
|
||||||
|
onChange={(e) => updatePlatform(index, "OS", e.target.value)}
|
||||||
|
placeholder="linux"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removePlatform(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addPlatform}
|
||||||
|
>
|
||||||
|
Add Platform
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Constraints: [],
|
||||||
|
Preferences: [],
|
||||||
|
MaxReplicas: undefined,
|
||||||
|
Platforms: [],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Placement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+219
@@ -0,0 +1,219 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const restartPolicyFormSchema = z.object({
|
||||||
|
Condition: z.string().optional(),
|
||||||
|
Delay: z.coerce.number().optional(),
|
||||||
|
MaxAttempts: z.coerce.number().optional(),
|
||||||
|
Window: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RestartPolicyFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(restartPolicyFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Condition: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
MaxAttempts: undefined,
|
||||||
|
Window: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.restartPolicySwarm) {
|
||||||
|
form.reset({
|
||||||
|
Condition: data.restartPolicySwarm.Condition,
|
||||||
|
Delay: data.restartPolicySwarm.Delay,
|
||||||
|
MaxAttempts: data.restartPolicySwarm.MaxAttempts,
|
||||||
|
Window: data.restartPolicySwarm.Window,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (
|
||||||
|
formData: z.infer<typeof restartPolicyFormSchema>,
|
||||||
|
) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue = Object.values(formData).some(
|
||||||
|
(value) => value !== undefined && value !== null && value !== "",
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
restartPolicySwarm: hasAnyValue ? formData : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Restart policy updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating restart policy");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Condition"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Condition</FormLabel>
|
||||||
|
<FormDescription>When to restart the container</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select restart condition" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
<SelectItem value="on-failure">On Failure</SelectItem>
|
||||||
|
<SelectItem value="any">Any</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Delay"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Wait time between restart attempts
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxAttempts"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Attempts</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum number of restart attempts
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="3" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Window"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Window (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Time window to evaluate restart policy
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Condition: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
MaxAttempts: undefined,
|
||||||
|
Window: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Restart Policy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const rollbackConfigFormSchema = z.object({
|
||||||
|
Parallelism: z.coerce.number().optional(),
|
||||||
|
Delay: z.coerce.number().optional(),
|
||||||
|
FailureAction: z.string().optional(),
|
||||||
|
Monitor: z.coerce.number().optional(),
|
||||||
|
MaxFailureRatio: z.coerce.number().optional(),
|
||||||
|
Order: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RollbackConfigFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(rollbackConfigFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.rollbackConfigSwarm) {
|
||||||
|
form.reset(data.rollbackConfigSwarm);
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (
|
||||||
|
formData: z.infer<typeof rollbackConfigFormSchema>,
|
||||||
|
) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue = Object.values(formData).some(
|
||||||
|
(value) => value !== undefined && value !== null && value !== "",
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Rollback config updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating rollback config");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Parallelism"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Parallelism</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Number of tasks to rollback simultaneously
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Delay"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>Delay between task rollbacks</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="FailureAction"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Failure Action</FormLabel>
|
||||||
|
<FormDescription>Action on rollback failure</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select failure action" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pause">Pause</SelectItem>
|
||||||
|
<SelectItem value="continue">Continue</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Monitor"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Monitor (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Duration to monitor for failure after rollback
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxFailureRatio"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Failure Ratio</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum failure ratio tolerated (0-1)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" step="0.01" placeholder="0.1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Order"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Order</FormLabel>
|
||||||
|
<FormDescription>Rollback order strategy</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select order" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="stop-first">Stop First</SelectItem>
|
||||||
|
<SelectItem value="start-first">Start First</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Rollback Config
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const hasStopGracePeriodSwarm = (
|
||||||
|
value: unknown,
|
||||||
|
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"stopGracePeriodSwarm" in value;
|
||||||
|
|
||||||
|
interface StopGracePeriodFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
defaultValues: {
|
||||||
|
value: null as bigint | null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasStopGracePeriodSwarm(data)) {
|
||||||
|
const value = data.stopGracePeriodSwarm;
|
||||||
|
const normalizedValue =
|
||||||
|
value === null || value === undefined
|
||||||
|
? null
|
||||||
|
: typeof value === "bigint"
|
||||||
|
? value
|
||||||
|
: BigInt(value);
|
||||||
|
form.reset({
|
||||||
|
value: normalizedValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: any) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
stopGracePeriodSwarm: formData.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Stop grace period updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating stop grace period");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="value"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Time to wait before forcefully killing the container
|
||||||
|
<br />
|
||||||
|
Examples: 30000000000 (30s), 120000000000 (2m)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="30000000000"
|
||||||
|
{...field}
|
||||||
|
value={
|
||||||
|
field?.value !== null && field?.value !== undefined
|
||||||
|
? field.value.toString()
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target.value ? BigInt(e.target.value) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
value: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Stop Grace Period
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const updateConfigFormSchema = z.object({
|
||||||
|
Parallelism: z.coerce.number().optional(),
|
||||||
|
Delay: z.coerce.number().optional(),
|
||||||
|
FailureAction: z.string().optional(),
|
||||||
|
Monitor: z.coerce.number().optional(),
|
||||||
|
MaxFailureRatio: z.coerce.number().optional(),
|
||||||
|
Order: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface UpdateConfigFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(updateConfigFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.updateConfigSwarm) {
|
||||||
|
const config = data.updateConfigSwarm;
|
||||||
|
form.reset({
|
||||||
|
Parallelism: config.Parallelism,
|
||||||
|
Delay: config.Delay,
|
||||||
|
FailureAction: config.FailureAction,
|
||||||
|
Monitor: config.Monitor,
|
||||||
|
MaxFailureRatio: config.MaxFailureRatio,
|
||||||
|
Order: config.Order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof updateConfigFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue = Object.values(formData).some(
|
||||||
|
(value) => value !== undefined && value !== null && value !== "",
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Update config updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating update config");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Parallelism"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Parallelism</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Number of tasks to update simultaneously
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Delay"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>Delay between task updates</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="FailureAction"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Failure Action</FormLabel>
|
||||||
|
<FormDescription>Action on update failure</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select failure action" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pause">Pause</SelectItem>
|
||||||
|
<SelectItem value="continue">Continue</SelectItem>
|
||||||
|
<SelectItem value="rollback">Rollback</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Monitor"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Monitor (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Duration to monitor for failure after update
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxFailureRatio"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Failure Ratio</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum failure ratio tolerated (0-1)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" step="0.01" placeholder="0.1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Order"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Order</FormLabel>
|
||||||
|
<FormDescription>Update order strategy</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select order" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="stop-first">Stop First</SelectItem>
|
||||||
|
<SelectItem value="start-first">Start First</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Update Config
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Filters out undefined, null, and empty string values from form data
|
||||||
|
* Only returns fields that have actual values
|
||||||
|
*/
|
||||||
|
export const filterEmptyValues = (
|
||||||
|
formData: Record<string, any>,
|
||||||
|
): Record<string, any> => {
|
||||||
|
return Object.entries(formData).reduce(
|
||||||
|
(acc, [key, value]) => {
|
||||||
|
// Keep arrays even if empty (they might be intentionally cleared)
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length > 0) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For other values, filter out undefined, null, and empty strings
|
||||||
|
else if (value !== undefined && value !== null && value !== "") {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, any>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if filtered data has any values to save
|
||||||
|
*/
|
||||||
|
export const hasValues = (data: Record<string, any>): boolean => {
|
||||||
|
return Object.keys(data).length > 0;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Cog } from "lucide-react";
|
import { Cog } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -20,8 +20,39 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||||
|
export const RAILPACK_VERSIONS = [
|
||||||
|
"0.15.4",
|
||||||
|
"0.15.3",
|
||||||
|
"0.15.2",
|
||||||
|
"0.15.1",
|
||||||
|
"0.15.0",
|
||||||
|
"0.14.0",
|
||||||
|
"0.13.0",
|
||||||
|
"0.12.0",
|
||||||
|
"0.11.0",
|
||||||
|
"0.10.0",
|
||||||
|
"0.9.2",
|
||||||
|
"0.9.1",
|
||||||
|
"0.9.0",
|
||||||
|
"0.8.0",
|
||||||
|
"0.7.0",
|
||||||
|
"0.6.0",
|
||||||
|
"0.5.0",
|
||||||
|
"0.4.0",
|
||||||
|
"0.3.0",
|
||||||
|
"0.2.2",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export enum BuildType {
|
export enum BuildType {
|
||||||
dockerfile = "dockerfile",
|
dockerfile = "dockerfile",
|
||||||
heroku_buildpacks = "heroku_buildpacks",
|
heroku_buildpacks = "heroku_buildpacks",
|
||||||
@@ -65,7 +96,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.railpack),
|
buildType: z.literal(BuildType.railpack),
|
||||||
railpackVersion: z.string().nullable().default("0.2.2"),
|
railpackVersion: z.string().nullable().default("0.15.4"),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.static),
|
buildType: z.literal(BuildType.static),
|
||||||
@@ -152,6 +183,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildType = form.watch("buildType");
|
const buildType = form.watch("buildType");
|
||||||
|
const railpackVersion = form.watch("railpackVersion");
|
||||||
|
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -163,9 +196,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
form.reset(resetData(typedData));
|
form.reset(resetData(typedData));
|
||||||
|
|
||||||
|
// Check if railpack version is manual (not in the predefined list)
|
||||||
|
if (
|
||||||
|
data.railpackVersion &&
|
||||||
|
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
|
||||||
|
) {
|
||||||
|
setIsManualRailpackVersion(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
|
|
||||||
|
// Hide builder section when Docker provider is selected
|
||||||
|
if (data?.sourceType === "docker") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = async (data: AddTemplate) => {
|
const onSubmit = async (data: AddTemplate) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -186,7 +232,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
||||||
railpackVersion:
|
railpackVersion:
|
||||||
data.buildType === BuildType.railpack
|
data.buildType === BuildType.railpack
|
||||||
? data.railpackVersion || "0.2.2"
|
? data.railpackVersion || "0.15.4"
|
||||||
: null,
|
: null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -403,23 +449,88 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{buildType === BuildType.railpack && (
|
{buildType === BuildType.railpack && (
|
||||||
<FormField
|
<>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="railpackVersion"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="railpackVersion"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Railpack Version</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Railpack Version</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
placeholder="Railpack Version"
|
{isManualRailpackVersion ? (
|
||||||
{...field}
|
<div className="space-y-2">
|
||||||
value={field.value ?? ""}
|
<Input
|
||||||
/>
|
placeholder="Enter custom version (e.g., 0.15.4)"
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
value={field.value ?? ""}
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
<Button
|
||||||
/>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsManualRailpackVersion(false);
|
||||||
|
field.onChange("0.15.4");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use predefined versions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "manual") {
|
||||||
|
setIsManualRailpackVersion(true);
|
||||||
|
field.onChange("");
|
||||||
|
} else {
|
||||||
|
field.onChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={field.value ?? "0.15.4"}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Railpack version" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="manual">
|
||||||
|
<span className="font-medium">
|
||||||
|
✏️ Manual (Custom Version)
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{RAILPACK_VERSIONS.map((version) => (
|
||||||
|
<SelectItem key={version} value={version}>
|
||||||
|
v{version}
|
||||||
|
{version === "0.15.4" && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-2 px-1 text-xs"
|
||||||
|
>
|
||||||
|
Latest
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Select a Railpack version or choose manual to enter a
|
||||||
|
custom version.{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/railwayapp/railpack/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-primary underline underline-offset-4"
|
||||||
|
>
|
||||||
|
View releases
|
||||||
|
</a>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
|
|||||||
@@ -256,9 +256,9 @@ export const ShowDeployments = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={deployment.deploymentId}
|
key={deployment.deploymentId}
|
||||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-1 flex-col min-w-0">
|
||||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||||
{index + 1}. {deployment.status}
|
{index + 1}. {deployment.status}
|
||||||
<StatusTooltip
|
<StatusTooltip
|
||||||
@@ -313,8 +313,8 @@ export const ShowDeployments = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
|
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
|
||||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
|
||||||
<DateTooltip date={deployment.createdAt} />
|
<DateTooltip date={deployment.createdAt} />
|
||||||
{deployment.startedAt && deployment.finishedAt && (
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -333,7 +333,7 @@ export const ShowDeployments = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
|
||||||
{deployment.pid && deployment.status === "running" && (
|
{deployment.pid && deployment.status === "running" && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Kill Process"
|
title="Kill Process"
|
||||||
@@ -355,6 +355,7 @@ export const ShowDeployments = ({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isKillingProcess}
|
isLoading={isKillingProcess}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Kill Process
|
Kill Process
|
||||||
</Button>
|
</Button>
|
||||||
@@ -364,6 +365,7 @@ export const ShowDeployments = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveLog(deployment);
|
setActiveLog(deployment);
|
||||||
}}
|
}}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
@@ -405,6 +407,7 @@ export const ShowDeployments = ({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isRollingBack}
|
isLoading={isRollingBack}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||||
Rollback
|
Rollback
|
||||||
|
|||||||
+2
-2
@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<FormItem className="md:col-span-2 flex flex-col">
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.gitlabPathNamespace && (
|
||||||
<Link
|
<Link
|
||||||
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
|
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
|||||||
+65
@@ -1,7 +1,9 @@
|
|||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
import {
|
import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
|
Hammer,
|
||||||
Loader2,
|
Loader2,
|
||||||
PenSquare,
|
PenSquare,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
@@ -22,6 +24,12 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||||
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||||
@@ -38,6 +46,9 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||||
api.previewDeployment.delete.useMutation();
|
api.previewDeployment.delete.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: redeployPreviewDeployment } =
|
||||||
|
api.previewDeployment.redeploy.useMutation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: previewDeployments,
|
data: previewDeployments,
|
||||||
refetch: refetchPreviewDeployments,
|
refetch: refetchPreviewDeployments,
|
||||||
@@ -46,6 +57,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
{ applicationId },
|
{ applicationId },
|
||||||
{
|
{
|
||||||
enabled: !!applicationId,
|
enabled: !!applicationId,
|
||||||
|
refetchInterval: (data) =>
|
||||||
|
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -193,6 +206,58 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</ShowDeploymentsModal>
|
</ShowDeploymentsModal>
|
||||||
|
|
||||||
|
<DialogAction
|
||||||
|
title="Rebuild Preview Deployment"
|
||||||
|
description="Are you sure you want to rebuild this preview deployment?"
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await redeployPreviewDeployment({
|
||||||
|
previewDeploymentId:
|
||||||
|
deployment.previewDeploymentId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
"Preview deployment rebuild started",
|
||||||
|
);
|
||||||
|
refetchPreviewDeployments();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(
|
||||||
|
"Error rebuilding preview deployment",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
isLoading={status === "running"}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Hammer className="size-4" />
|
||||||
|
Rebuild
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipContent
|
||||||
|
sideOffset={5}
|
||||||
|
className="z-[60]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Rebuild the preview deployment without
|
||||||
|
downloading new code
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
|
||||||
<AddPreviewDomain
|
<AddPreviewDomain
|
||||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||||
domainId={deployment.domain?.domainId}
|
domainId={deployment.domain?.domainId}
|
||||||
|
|||||||
+13
-3
@@ -1,7 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const gitlabId = form.watch("gitlabId");
|
const gitlabId = form.watch("gitlabId");
|
||||||
|
|
||||||
|
const gitlabUrl = useMemo(() => {
|
||||||
|
const url = gitlabProviders?.find(
|
||||||
|
(provider) => provider.gitlabId === gitlabId,
|
||||||
|
)?.gitlabUrl;
|
||||||
|
|
||||||
|
const gitlabUrl = url?.replace(/\/$/, "");
|
||||||
|
|
||||||
|
return gitlabUrl || "https://gitlab.com";
|
||||||
|
}, [gitlabId, gitlabProviders]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: repositories,
|
data: repositories,
|
||||||
isLoading: isLoadingRepositories,
|
isLoading: isLoadingRepositories,
|
||||||
@@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormItem className="md:col-span-2 flex flex-col">
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.gitlabPathNamespace && (
|
||||||
<Link
|
<Link
|
||||||
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
|
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
|||||||
@@ -559,6 +559,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="******************"
|
placeholder="******************"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
|
enablePasswordGenerator={true}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -578,6 +579,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="******************"
|
placeholder="******************"
|
||||||
|
enablePasswordGenerator={true}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -420,7 +420,7 @@ export const ShowProjects = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between gap-2">
|
<CardTitle className="flex items-center justify-between gap-2">
|
||||||
<span className="flex flex-col gap-1.5">
|
<span className="flex flex-col gap-1.5 ">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookIcon className="size-4 text-muted-foreground" />
|
<BookIcon className="size-4 text-muted-foreground" />
|
||||||
<span className="text-base font-medium leading-none">
|
<span className="text-base font-medium leading-none">
|
||||||
@@ -428,7 +428,7 @@ export const ShowProjects = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
<span className="text-sm font-medium text-muted-foreground break-all">
|
||||||
{project.description}
|
{project.description}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { CreditCard, FileText } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ShowInvoices } from "./show-invoices";
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
name: "Subscription",
|
||||||
|
href: "/dashboard/settings/billing",
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invoices",
|
||||||
|
href: "/dashboard/settings/invoices",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ShowBillingInvoices = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||||
|
Billing
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage your subscription and invoices
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 py-4 border-t">
|
||||||
|
<nav className="flex space-x-2 border-b">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = router.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<ShowInvoices />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,11 +4,13 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => {
|
|||||||
if (count <= 1) return 4.5;
|
if (count <= 1) return 4.5;
|
||||||
return count * 3.5;
|
return count * 3.5;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
name: "Subscription",
|
||||||
|
href: "/dashboard/settings/billing",
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invoices",
|
||||||
|
href: "/dashboard/settings/invoices",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const ShowBilling = () => {
|
export const ShowBilling = () => {
|
||||||
|
const router = useRouter();
|
||||||
const { data: servers } = api.server.count.useQuery();
|
const { data: servers } = api.server.count.useQuery();
|
||||||
const { data: admin } = api.user.get.useQuery();
|
const { data: admin } = api.user.get.useQuery();
|
||||||
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
||||||
@@ -76,17 +93,41 @@ export const ShowBilling = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
<CardHeader className="">
|
<CardHeader>
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||||
Billing
|
Billing
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Manage your subscription</CardDescription>
|
<CardDescription>
|
||||||
|
Manage your subscription and invoices
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-4 py-4 border-t">
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<nav className="flex space-x-2 border-b">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = router.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 w-full mt-6">
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="monthly"
|
defaultValue="monthly"
|
||||||
value={isAnnual ? "annual" : "monthly"}
|
value={isAnnual ? "annual" : "monthly"}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number | null) => {
|
||||||
|
if (!timestamp) return "-";
|
||||||
|
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount: number, currency: string) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
}).format(amount / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
|
||||||
|
const statusConfig: Record<
|
||||||
|
Stripe.Invoice.Status,
|
||||||
|
{ label: string; variant: "default" | "secondary" | "destructive" }
|
||||||
|
> = {
|
||||||
|
paid: { label: "Paid", variant: "default" },
|
||||||
|
open: { label: "Open", variant: "secondary" },
|
||||||
|
draft: { label: "Draft", variant: "secondary" },
|
||||||
|
void: { label: "Void", variant: "destructive" },
|
||||||
|
uncollectible: { label: "Uncollectible", variant: "destructive" },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return <Badge variant="secondary">Unknown</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = statusConfig[status] || {
|
||||||
|
label: status,
|
||||||
|
variant: "secondary" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowInvoices = () => {
|
||||||
|
const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center min-h-[20vh]">
|
||||||
|
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center">
|
||||||
|
Loading invoices...
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : invoices && invoices.length > 0 ? (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Invoice</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Due Date</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<TableRow key={invoice.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{invoice.number || invoice.id.slice(0, 12)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(invoice.created)}</TableCell>
|
||||||
|
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatAmount(invoice.amountDue, invoice.currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{invoice.hostedInvoiceUrl && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
invoice.hostedInvoiceUrl || "",
|
||||||
|
"_blank",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{invoice.invoicePdf && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(invoice.invoicePdf || "", "_blank")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[20vh] gap-2">
|
||||||
|
<FileText className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">No invoices found</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your invoices will appear here once you have a subscription
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -89,15 +88,15 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem
|
<Button
|
||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
onSelect={(e) => {
|
size="sm"
|
||||||
e.preventDefault();
|
onClick={() => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Setup Server
|
Setup Server <Settings className="size-4" />
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DialogContent className="sm:max-w-4xl ">
|
<DialogContent className="sm:max-w-4xl ">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Network,
|
Network,
|
||||||
Pencil,
|
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
Settings,
|
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
@@ -31,9 +29,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
@@ -285,7 +281,32 @@ export const ShowServers = () => {
|
|||||||
|
|
||||||
{/* Compact Actions */}
|
{/* Compact Actions */}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="flex items-center gap-2 pt-3 border-t mt-auto">
|
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<SetupServer
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="max-w-xs"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold">
|
||||||
|
Setup Server
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure and initialize your
|
||||||
|
server with Docker, Traefik, and
|
||||||
|
other essential services
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{server.sshKeyId && (
|
{server.sshKeyId && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -311,20 +332,6 @@ export const ShowServers = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<SetupServer
|
|
||||||
serverId={server.serverId}
|
|
||||||
asButton={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Setup Server</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center space-x-2">
|
<div className="flex w-full items-center space-x-2">
|
||||||
<Input ref={inputRef} type={"password"} {...props} />
|
<Input ref={inputRef} {...props} type="password" />
|
||||||
<Button
|
<Button
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,18 +1,75 @@
|
|||||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { generateRandomPassword } from "@/lib/password-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
enablePasswordGenerator?: boolean;
|
||||||
|
passwordGeneratorLength?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, errorMessage, type, ...props }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
errorMessage,
|
||||||
|
type,
|
||||||
|
enablePasswordGenerator = false,
|
||||||
|
passwordGeneratorLength,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const [showPassword, setShowPassword] = React.useState(false);
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const isPassword = type === "password";
|
const isPassword = type === "password";
|
||||||
|
const shouldShowGenerator =
|
||||||
|
isPassword &&
|
||||||
|
enablePasswordGenerator !== false &&
|
||||||
|
!props.disabled &&
|
||||||
|
!props.readOnly;
|
||||||
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
|
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
|
||||||
|
|
||||||
|
const setRefs = React.useCallback(
|
||||||
|
(node: HTMLInputElement | null) => {
|
||||||
|
// @ts-ignore
|
||||||
|
inputRef.current = node;
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(node);
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = node;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGeneratePassword = () => {
|
||||||
|
const nextValue =
|
||||||
|
typeof passwordGeneratorLength === "number" &&
|
||||||
|
passwordGeneratorLength > 0
|
||||||
|
? generateRandomPassword(Math.floor(passwordGeneratorLength))
|
||||||
|
: generateRandomPassword();
|
||||||
|
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
if (valueSetter) {
|
||||||
|
valueSetter.call(input, nextValue);
|
||||||
|
} else {
|
||||||
|
input.value = nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
@@ -21,25 +78,39 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
className={cn(
|
className={cn(
|
||||||
// bg-gray
|
// bg-gray
|
||||||
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
isPassword && "pr-10", // Add padding for the eye icon
|
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={setRefs}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{isPassword && (
|
{isPassword && (
|
||||||
<button
|
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
|
||||||
type="button"
|
{shouldShowGenerator && (
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground focus:outline-none"
|
<button
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
type="button"
|
||||||
tabIndex={-1}
|
className="hover:text-foreground focus:outline-none"
|
||||||
>
|
onClick={handleGeneratePassword}
|
||||||
{showPassword ? (
|
aria-label="Generate password"
|
||||||
<EyeOffIcon className="h-4 w-4" />
|
title="Generate password"
|
||||||
) : (
|
tabIndex={-1}
|
||||||
<EyeIcon className="h-4 w-4" />
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:text-foreground focus:outline-none"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOffIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS "ai" (
|
|
||||||
"aiId" text PRIMARY KEY NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"apiUrl" text NOT NULL,
|
|
||||||
"apiKey" text NOT NULL,
|
|
||||||
"model" text NOT NULL,
|
|
||||||
"isEnabled" boolean DEFAULT true NOT NULL,
|
|
||||||
"adminId" text NOT NULL,
|
|
||||||
"createdAt" text NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "ai" ADD CONSTRAINT "ai_adminId_admin_adminId_fk" FOREIGN KEY ("adminId") REFERENCES "public"."admin"("adminId") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "application" ALTER COLUMN "railpackVersion" SET DEFAULT '0.15.4';
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -939,6 +939,13 @@
|
|||||||
"when": 1766301478005,
|
"when": 1766301478005,
|
||||||
"tag": "0133_striped_the_order",
|
"tag": "0133_striped_the_order",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 134,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1767871040249,
|
||||||
|
"tag": "0134_strong_hercules",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
const DEFAULT_PASSWORD_LENGTH = 20;
|
||||||
|
const DEFAULT_PASSWORD_CHARSET =
|
||||||
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
|
|
||||||
|
export const generateRandomPassword = (
|
||||||
|
length: number = DEFAULT_PASSWORD_LENGTH,
|
||||||
|
charset: string = DEFAULT_PASSWORD_CHARSET,
|
||||||
|
) => {
|
||||||
|
const safeLength =
|
||||||
|
Number.isFinite(length) && length > 0
|
||||||
|
? Math.floor(length)
|
||||||
|
: DEFAULT_PASSWORD_LENGTH;
|
||||||
|
|
||||||
|
if (safeLength <= 0 || charset.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const cryptoApi =
|
||||||
|
typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
|
||||||
|
|
||||||
|
if (!cryptoApi?.getRandomValues) {
|
||||||
|
let fallback = "";
|
||||||
|
for (let i = 0; i < safeLength; i += 1) {
|
||||||
|
fallback += charset[Math.floor(Math.random() * charset.length)];
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = new Uint32Array(safeLength);
|
||||||
|
cryptoApi.getRandomValues(values);
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
for (const value of values) {
|
||||||
|
result += charset[value % charset.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.26.3",
|
"version": "v0.26.5",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -109,7 +109,6 @@
|
|||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"fancy-ansi": "^0.1.3",
|
"fancy-ansi": "^0.1.3",
|
||||||
"hi-base32": "^0.5.1",
|
|
||||||
"i18next": "^23.16.8",
|
"i18next": "^23.16.8",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
@@ -126,7 +125,6 @@
|
|||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "6.9.14",
|
||||||
"octokit": "3.1.2",
|
"octokit": "3.1.2",
|
||||||
"otpauth": "^9.4.0",
|
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
"postgres": "3.4.4",
|
"postgres": "3.4.4",
|
||||||
@@ -155,9 +153,11 @@
|
|||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"yaml": "2.8.1",
|
"yaml": "2.8.1",
|
||||||
"zod": "^3.25.32",
|
"zod": "^3.25.32",
|
||||||
"zod-form-data": "^2.0.7"
|
"zod-form-data": "^2.0.7",
|
||||||
|
"semver": "7.7.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/semver": "7.7.1",
|
||||||
"@types/shell-quote": "^1.7.5",
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
@@ -196,10 +196,5 @@
|
|||||||
"*": [
|
"*": [
|
||||||
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
|
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"commitlint": {
|
|
||||||
"extends": [
|
|
||||||
"@commitlint/config-conventional"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -909,7 +909,9 @@ const EnvironmentPage = (
|
|||||||
<ProjectEnvironment projectId={projectId}>
|
<ProjectEnvironment projectId={projectId}>
|
||||||
<Button variant="outline">Project Environment</Button>
|
<Button variant="outline">Project Environment</Button>
|
||||||
</ProjectEnvironment>
|
</ProjectEnvironment>
|
||||||
{(auth?.role === "owner" || auth?.canCreateServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canCreateServices) && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button>
|
<Button>
|
||||||
@@ -1032,6 +1034,7 @@ const EnvironmentPage = (
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
{(auth?.role === "owner" ||
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
auth?.canDeleteServices) && (
|
auth?.canDeleteServices) && (
|
||||||
<>
|
<>
|
||||||
<DialogAction
|
<DialogAction
|
||||||
|
|||||||
+4
-1
@@ -108,6 +108,7 @@ const Service = (
|
|||||||
{ name: "Projects", href: "/dashboard/projects" },
|
{ name: "Projects", href: "/dashboard/projects" },
|
||||||
{
|
{
|
||||||
name: data?.environment?.project?.name || "",
|
name: data?.environment?.project?.name || "",
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
@@ -192,7 +193,9 @@ const Service = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateApplication applicationId={applicationId} />
|
<UpdateApplication applicationId={applicationId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={applicationId} type="application" />
|
<DeleteService id={applicationId} type="application" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-1
@@ -97,6 +97,7 @@ const Service = (
|
|||||||
{ name: "Projects", href: "/dashboard/projects" },
|
{ name: "Projects", href: "/dashboard/projects" },
|
||||||
{
|
{
|
||||||
name: data?.environment?.project?.name || "",
|
name: data?.environment?.project?.name || "",
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
@@ -182,7 +183,9 @@ const Service = (
|
|||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateCompose composeId={composeId} />
|
<UpdateCompose composeId={composeId} />
|
||||||
|
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={composeId} type="compose" />
|
<DeleteService id={composeId} type="compose" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-1
@@ -79,6 +79,7 @@ const Mariadb = (
|
|||||||
{ name: "Projects", href: "/dashboard/projects" },
|
{ name: "Projects", href: "/dashboard/projects" },
|
||||||
{
|
{
|
||||||
name: data?.environment?.project?.name || "",
|
name: data?.environment?.project?.name || "",
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
@@ -156,7 +157,9 @@ const Mariadb = (
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateMariadb mariadbId={mariadbId} />
|
<UpdateMariadb mariadbId={mariadbId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={mariadbId} type="mariadb" />
|
<DeleteService id={mariadbId} type="mariadb" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-1
@@ -78,6 +78,7 @@ const Mongo = (
|
|||||||
{ name: "Projects", href: "/dashboard/projects" },
|
{ name: "Projects", href: "/dashboard/projects" },
|
||||||
{
|
{
|
||||||
name: data?.environment?.project?.name || "",
|
name: data?.environment?.project?.name || "",
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
@@ -155,7 +156,9 @@ const Mongo = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateMongo mongoId={mongoId} />
|
<UpdateMongo mongoId={mongoId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={mongoId} type="mongo" />
|
<DeleteService id={mongoId} type="mongo" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-1
@@ -77,6 +77,7 @@ const MySql = (
|
|||||||
{ name: "Projects", href: "/dashboard/projects" },
|
{ name: "Projects", href: "/dashboard/projects" },
|
||||||
{
|
{
|
||||||
name: data?.environment?.project?.name || "",
|
name: data?.environment?.project?.name || "",
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
@@ -156,7 +157,9 @@ const MySql = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateMysql mysqlId={mysqlId} />
|
<UpdateMysql mysqlId={mysqlId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={mysqlId} type="mysql" />
|
<DeleteService id={mysqlId} type="mysql" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-1
@@ -77,6 +77,7 @@ const Postgresql = (
|
|||||||
{ name: "Projects", href: "/dashboard/projects" },
|
{ name: "Projects", href: "/dashboard/projects" },
|
||||||
{
|
{
|
||||||
name: data?.environment?.project?.name || "",
|
name: data?.environment?.project?.name || "",
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
@@ -154,7 +155,9 @@ const Postgresql = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdatePostgres postgresId={postgresId} />
|
<UpdatePostgres postgresId={postgresId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={postgresId} type="postgres" />
|
<DeleteService id={postgresId} type="postgres" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-1
@@ -77,6 +77,7 @@ const Redis = (
|
|||||||
{ name: "Projects", href: "/dashboard/projects" },
|
{ name: "Projects", href: "/dashboard/projects" },
|
||||||
{
|
{
|
||||||
name: data?.environment?.project?.name || "",
|
name: data?.environment?.project?.name || "",
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
@@ -154,7 +155,9 @@ const Redis = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateRedis redisId={redisId} />
|
<UpdateRedis redisId={redisId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={redisId} type="redis" />
|
<DeleteService id={redisId} type="redis" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import { ShowBillingInvoices } from "@/components/dashboard/settings/billing/show-billing-invoices";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import { appRouter } from "@/server/api/root";
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
return <ShowBillingInvoices />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
|
|
||||||
|
Page.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout metaName="Invoices">{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
export async function getServerSideProps(
|
||||||
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
|
) {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/dashboard/projects",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { req, res } = ctx;
|
||||||
|
const { user, session } = await validateRequest(req);
|
||||||
|
if (!user || user.role !== "owner") {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpers = createServerSideHelpers({
|
||||||
|
router: appRouter,
|
||||||
|
ctx: {
|
||||||
|
req: req as any,
|
||||||
|
res: res as any,
|
||||||
|
db: null as any,
|
||||||
|
session: session as any,
|
||||||
|
user: user as any,
|
||||||
|
},
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
|
||||||
|
await helpers.user.get.prefetch();
|
||||||
|
|
||||||
|
await helpers.settings.isCloud.prefetch();
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -285,6 +285,7 @@ export const backupRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const backup = await findBackupById(input.backupId);
|
const backup = await findBackupById(input.backupId);
|
||||||
await runWebServerBackup(backup);
|
await runWebServerBackup(backup);
|
||||||
|
await keepLatestNBackups(backup);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
listBackupFiles: protectedProcedure
|
listBackupFiles: protectedProcedure
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import {
|
|||||||
findApplicationById,
|
findApplicationById,
|
||||||
findPreviewDeploymentById,
|
findPreviewDeploymentById,
|
||||||
findPreviewDeploymentsByApplicationId,
|
findPreviewDeploymentsByApplicationId,
|
||||||
|
IS_CLOUD,
|
||||||
removePreviewDeployment,
|
removePreviewDeployment,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiFindAllByApplication } from "@/server/db/schema";
|
import { apiFindAllByApplication } from "@/server/db/schema";
|
||||||
|
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||||
|
import { myQueue } from "@/server/queues/queueSetup";
|
||||||
|
import { deploy } from "@/server/utils/deploy";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
export const previewDeploymentRouter = createTRPCRouter({
|
export const previewDeploymentRouter = createTRPCRouter({
|
||||||
@@ -60,4 +64,55 @@ export const previewDeploymentRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return previewDeployment;
|
return previewDeployment;
|
||||||
}),
|
}),
|
||||||
|
redeploy: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
previewDeploymentId: z.string(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const previewDeployment = await findPreviewDeploymentById(
|
||||||
|
input.previewDeploymentId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
previewDeployment.application.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to redeploy this preview deployment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const application = await findApplicationById(
|
||||||
|
previewDeployment.applicationId,
|
||||||
|
);
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
applicationId: previewDeployment.applicationId,
|
||||||
|
titleLog: input.title || "Rebuild Preview Deployment",
|
||||||
|
descriptionLog: input.description || "",
|
||||||
|
type: "redeploy",
|
||||||
|
applicationType: "application-preview",
|
||||||
|
previewDeploymentId: input.previewDeploymentId,
|
||||||
|
server: !!application.serverId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD && application.serverId) {
|
||||||
|
jobData.serverId = application.serverId;
|
||||||
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await reloadDockerResource("dokploy");
|
await reloadDockerResource("dokploy", undefined, packageInfo.version);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cleanRedis: adminProcedure.mutation(async () => {
|
cleanRedis: adminProcedure.mutation(async () => {
|
||||||
@@ -399,7 +399,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
return DEFAULT_UPDATE_DATA;
|
return DEFAULT_UPDATE_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getUpdateData();
|
return await getUpdateData(packageInfo.version);
|
||||||
}),
|
}),
|
||||||
updateServer: adminProcedure.mutation(async () => {
|
updateServer: adminProcedure.mutation(async () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
|
|||||||
@@ -75,9 +75,9 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
line_items: items,
|
line_items: items,
|
||||||
...(stripeCustomerId && {
|
...(stripeCustomerId
|
||||||
customer: stripeCustomerId,
|
? { customer: stripeCustomerId }
|
||||||
}),
|
: { customer_email: owner.email }),
|
||||||
metadata: {
|
metadata: {
|
||||||
adminId: owner.id,
|
adminId: owner.id,
|
||||||
},
|
},
|
||||||
@@ -128,4 +128,39 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return servers.length < user.serversQuantity;
|
return servers.length < user.serversQuantity;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getInvoices: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
const user = await findUserById(ctx.user.ownerId);
|
||||||
|
const stripeCustomerId = user.stripeCustomerId;
|
||||||
|
|
||||||
|
if (!stripeCustomerId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||||
|
apiVersion: "2024-09-30.acacia",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invoices = await stripe.invoices.list({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return invoices.data.map((invoice) => ({
|
||||||
|
id: invoice.id,
|
||||||
|
number: invoice.number,
|
||||||
|
status: invoice.status,
|
||||||
|
amountDue: invoice.amount_due,
|
||||||
|
amountPaid: invoice.amount_paid,
|
||||||
|
currency: invoice.currency,
|
||||||
|
created: invoice.created,
|
||||||
|
dueDate: invoice.due_date,
|
||||||
|
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||||
|
invoicePdf: invoice.invoice_pdf,
|
||||||
|
}));
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
deployPreviewApplication,
|
deployPreviewApplication,
|
||||||
rebuildApplication,
|
rebuildApplication,
|
||||||
rebuildCompose,
|
rebuildCompose,
|
||||||
|
rebuildPreviewApplication,
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -54,7 +55,14 @@ export const deploymentWorker = new Worker(
|
|||||||
previewStatus: "running",
|
previewStatus: "running",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (job.data.type === "deploy") {
|
if (job.data.type === "redeploy") {
|
||||||
|
await rebuildPreviewApplication({
|
||||||
|
applicationId: job.data.applicationId,
|
||||||
|
titleLog: job.data.titleLog,
|
||||||
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
previewDeploymentId: job.data.previewDeploymentId,
|
||||||
|
});
|
||||||
|
} else if (job.data.type === "deploy") {
|
||||||
await deployPreviewApplication({
|
await deployPreviewApplication({
|
||||||
applicationId: job.data.applicationId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type DeployJob =
|
|||||||
titleLog: string;
|
titleLog: string;
|
||||||
descriptionLog: string;
|
descriptionLog: string;
|
||||||
server?: boolean;
|
server?: boolean;
|
||||||
type: "deploy";
|
type: "deploy" | "redeploy";
|
||||||
applicationType: "application-preview";
|
applicationType: "application-preview";
|
||||||
previewDeploymentId: string;
|
previewDeploymentId: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta
|
|||||||
WITH recent_metrics AS (
|
WITH recent_metrics AS (
|
||||||
SELECT metrics_json
|
SELECT metrics_json
|
||||||
FROM container_metrics
|
FROM container_metrics
|
||||||
WHERE container_name LIKE ? || '%'
|
WHERE container_name = ?
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
)
|
)
|
||||||
@@ -98,7 +98,7 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e
|
|||||||
WITH recent_metrics AS (
|
WITH recent_metrics AS (
|
||||||
SELECT metrics_json
|
SELECT metrics_json
|
||||||
FROM container_metrics
|
FROM container_metrics
|
||||||
WHERE container_name LIKE ? || '%'
|
WHERE container_name = ?
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
)
|
)
|
||||||
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# EXAMPLE USAGE:
|
|
||||||
#
|
|
||||||
# Refer for explanation to following link:
|
|
||||||
# https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md
|
|
||||||
#
|
|
||||||
# pre-push:
|
|
||||||
# commands:
|
|
||||||
# packages-audit:
|
|
||||||
# tags: frontend security
|
|
||||||
# run: yarn audit
|
|
||||||
# gems-audit:
|
|
||||||
# tags: backend security
|
|
||||||
# run: bundle audit
|
|
||||||
#
|
|
||||||
# pre-commit:
|
|
||||||
# parallel: true
|
|
||||||
# commands:
|
|
||||||
# eslint:
|
|
||||||
# glob: "*.{js,ts,jsx,tsx}"
|
|
||||||
# run: yarn eslint {staged_files}
|
|
||||||
# rubocop:
|
|
||||||
# tags: backend style
|
|
||||||
# glob: "*.rb"
|
|
||||||
# exclude: '(^|/)(application|routes)\.rb$'
|
|
||||||
# run: bundle exec rubocop --force-exclusion {all_files}
|
|
||||||
# govet:
|
|
||||||
# tags: backend style
|
|
||||||
# files: git ls-files -m
|
|
||||||
# glob: "*.go"
|
|
||||||
# run: go vet {files}
|
|
||||||
# scripts:
|
|
||||||
# "hello.js":
|
|
||||||
# runner: node
|
|
||||||
# "any.go":
|
|
||||||
# runner: go run
|
|
||||||
|
|
||||||
commit-msg:
|
|
||||||
commands:
|
|
||||||
commitlint:
|
|
||||||
# run: "npx commitlint --edit $1"
|
|
||||||
|
|
||||||
pre-commit:
|
|
||||||
commands:
|
|
||||||
check:
|
|
||||||
# run: "pnpm check"
|
|
||||||
@@ -24,12 +24,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.1.1",
|
"@biomejs/biome": "2.1.1",
|
||||||
"@commitlint/cli": "^19.8.1",
|
|
||||||
"@commitlint/config-conventional": "^19.8.1",
|
|
||||||
"@types/node": "^18.19.104",
|
"@types/node": "^18.19.104",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"esbuild": "0.20.2",
|
"esbuild": "0.20.2",
|
||||||
"lefthook": "1.8.4",
|
|
||||||
"lint-staged": "^15.5.2",
|
"lint-staged": "^15.5.2",
|
||||||
"tsx": "4.16.2"
|
"tsx": "4.16.2"
|
||||||
},
|
},
|
||||||
@@ -43,11 +40,6 @@
|
|||||||
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
|
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"commitlint": {
|
|
||||||
"extends": [
|
|
||||||
"@commitlint/config-conventional"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/react": "18.3.5",
|
"@types/react": "18.3.5",
|
||||||
"@types/react-dom": "18.3.0"
|
"@types/react-dom": "18.3.0"
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
"drizzle-dbml-generator": "0.10.0",
|
"drizzle-dbml-generator": "0.10.0",
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"hi-base32": "^0.5.1",
|
|
||||||
"yaml": "2.8.1",
|
"yaml": "2.8.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
@@ -67,7 +66,6 @@
|
|||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "6.9.14",
|
||||||
"octokit": "3.1.2",
|
"octokit": "3.1.2",
|
||||||
"otpauth": "^9.4.0",
|
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
"postgres": "3.4.4",
|
"postgres": "3.4.4",
|
||||||
@@ -80,9 +78,11 @@
|
|||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
"toml": "3.0.0",
|
"toml": "3.0.0",
|
||||||
"ws": "8.16.0",
|
"ws": "8.16.0",
|
||||||
"zod": "^3.25.32"
|
"zod": "^3.25.32",
|
||||||
|
"semver": "7.7.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/semver": "7.7.1",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/dockerode": "3.3.23",
|
"@types/dockerode": "3.3.23",
|
||||||
@@ -111,4 +111,4 @@
|
|||||||
"node": "^20.16.0",
|
"node": "^20.16.0",
|
||||||
"pnpm": ">=9.12.0"
|
"pnpm": ">=9.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ table application {
|
|||||||
replicas integer [not null, default: 1]
|
replicas integer [not null, default: 1]
|
||||||
applicationStatus applicationStatus [not null, default: 'idle']
|
applicationStatus applicationStatus [not null, default: 'idle']
|
||||||
buildType buildType [not null, default: 'nixpacks']
|
buildType buildType [not null, default: 'nixpacks']
|
||||||
railpackVersion text [default: '0.2.2']
|
railpackVersion text [default: '0.15.4']
|
||||||
herokuVersion text [default: '24']
|
herokuVersion text [default: '24']
|
||||||
publishDirectory text
|
publishDirectory text
|
||||||
isStaticSpa boolean
|
isStaticSpa boolean
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export const applications = pgTable("application", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default("idle"),
|
.default("idle"),
|
||||||
buildType: buildType("buildType").notNull().default("nixpacks"),
|
buildType: buildType("buildType").notNull().default("nixpacks"),
|
||||||
railpackVersion: text("railpackVersion").default("0.2.2"),
|
railpackVersion: text("railpackVersion").default("0.15.4"),
|
||||||
herokuVersion: text("herokuVersion").default("24"),
|
herokuVersion: text("herokuVersion").default("24"),
|
||||||
publishDirectory: text("publishDirectory"),
|
publishDirectory: text("publishDirectory"),
|
||||||
isStaticSpa: boolean("isStaticSpa"),
|
isStaticSpa: boolean("isStaticSpa"),
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ const { handler, api } = betterAuth({
|
|||||||
return [
|
return [
|
||||||
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||||
...(settings?.host ? [`https://${settings?.host}`] : []),
|
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||||
|
...(process.env.NODE_ENV === "development"
|
||||||
|
? [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"https://absolutely-handy-falcon.ngrok-free.app",
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -452,6 +452,137 @@ export const deployPreviewApplication = async ({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const rebuildPreviewApplication = async ({
|
||||||
|
applicationId,
|
||||||
|
titleLog = "Rebuild Preview Deployment",
|
||||||
|
descriptionLog = "",
|
||||||
|
previewDeploymentId,
|
||||||
|
}: {
|
||||||
|
applicationId: string;
|
||||||
|
titleLog: string;
|
||||||
|
descriptionLog: string;
|
||||||
|
previewDeploymentId: string;
|
||||||
|
}) => {
|
||||||
|
const application = await findApplicationById(applicationId);
|
||||||
|
const previewDeployment =
|
||||||
|
await findPreviewDeploymentById(previewDeploymentId);
|
||||||
|
|
||||||
|
const deployment = await createDeploymentPreview({
|
||||||
|
title: titleLog,
|
||||||
|
description: descriptionLog,
|
||||||
|
previewDeploymentId: previewDeploymentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewDomain = getDomainHost(previewDeployment?.domain as Domain);
|
||||||
|
const issueParams = {
|
||||||
|
owner: application?.owner || "",
|
||||||
|
repository: application?.repository || "",
|
||||||
|
issue_number: previewDeployment.pullRequestNumber,
|
||||||
|
comment_id: Number.parseInt(previewDeployment.pullRequestCommentId),
|
||||||
|
githubId: application?.githubId || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commentExists = await issueCommentExists({
|
||||||
|
...issueParams,
|
||||||
|
});
|
||||||
|
if (!commentExists) {
|
||||||
|
const result = await createPreviewDeploymentComment({
|
||||||
|
...issueParams,
|
||||||
|
previewDomain,
|
||||||
|
appName: previewDeployment.appName,
|
||||||
|
githubId: application?.githubId || "",
|
||||||
|
previewDeploymentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Pull request comment not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildingComment = getIssueComment(
|
||||||
|
application.name,
|
||||||
|
"running",
|
||||||
|
previewDomain,
|
||||||
|
);
|
||||||
|
await updateIssueComment({
|
||||||
|
...issueParams,
|
||||||
|
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set application properties for preview deployment
|
||||||
|
application.appName = previewDeployment.appName;
|
||||||
|
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||||
|
application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||||
|
application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||||
|
application.rollbackActive = false;
|
||||||
|
application.buildRegistry = null;
|
||||||
|
application.rollbackRegistry = null;
|
||||||
|
application.registry = null;
|
||||||
|
|
||||||
|
const serverId = application.serverId;
|
||||||
|
let command = "set -e;";
|
||||||
|
// Only rebuild, don't clone repository
|
||||||
|
command += await getBuildCommand(application);
|
||||||
|
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, commandWithLog);
|
||||||
|
} else {
|
||||||
|
await execAsync(commandWithLog);
|
||||||
|
}
|
||||||
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
|
const successComment = getIssueComment(
|
||||||
|
application.name,
|
||||||
|
"success",
|
||||||
|
previewDomain,
|
||||||
|
);
|
||||||
|
await updateIssueComment({
|
||||||
|
...issueParams,
|
||||||
|
body: `### Dokploy Preview Deployment\n\n${successComment}`,
|
||||||
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
|
await updatePreviewDeployment(previewDeploymentId, {
|
||||||
|
previewStatus: "done",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
let command = "";
|
||||||
|
|
||||||
|
// Only log details for non-ExecError errors
|
||||||
|
if (!(error instanceof ExecError)) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const encodedMessage = encodeBase64(message);
|
||||||
|
command += `echo "${encodedMessage}" | base64 -d >> "${deployment.logPath}";`;
|
||||||
|
}
|
||||||
|
|
||||||
|
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
|
||||||
|
const serverId = application.buildServerId || application.serverId;
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = getIssueComment(application.name, "error", previewDomain);
|
||||||
|
await updateIssueComment({
|
||||||
|
...issueParams,
|
||||||
|
body: `### Dokploy Preview Deployment\n\n${comment}`,
|
||||||
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
await updatePreviewDeployment(previewDeploymentId, {
|
||||||
|
previewStatus: "error",
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
export const getApplicationStats = async (appName: string) => {
|
export const getApplicationStats = async (appName: string) => {
|
||||||
if (appName === "dokploy") {
|
if (appName === "dokploy") {
|
||||||
return await getAdvancedStats(appName);
|
return await getAdvancedStats(appName);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import dns from "node:dns";
|
import dns from "node:dns";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||||
import { generateRandomDomain } from "@dokploy/server/templates";
|
import { generateRandomDomain } from "@dokploy/server/templates";
|
||||||
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
|
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
|
||||||
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { type apiCreateDomain, domains } from "../db/schema";
|
import { type apiCreateDomain, domains } from "../db/schema";
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
execAsync,
|
execAsync,
|
||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
} from "@dokploy/server/utils/process/execAsync";
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import semver from "semver";
|
||||||
import {
|
import {
|
||||||
initializeStandaloneTraefik,
|
initializeStandaloneTraefik,
|
||||||
initializeTraefikService,
|
initializeTraefikService,
|
||||||
type TraefikOptions,
|
type TraefikOptions,
|
||||||
} from "../setup/traefik-setup";
|
} from "../setup/traefik-setup";
|
||||||
|
|
||||||
export interface IUpdateData {
|
export interface IUpdateData {
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
@@ -55,56 +55,95 @@ export const getServiceImageDigest = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */
|
/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */
|
||||||
export const getUpdateData = async (): Promise<IUpdateData> => {
|
export const getUpdateData = async (
|
||||||
let currentDigest: string;
|
currentVersion: string,
|
||||||
|
): Promise<IUpdateData> => {
|
||||||
try {
|
try {
|
||||||
currentDigest = await getServiceImageDigest();
|
const baseUrl =
|
||||||
} catch (error) {
|
"https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
|
||||||
// TODO: Docker versions 29.0.0 change the way to get the service image digest, so we need to update this in the future we upgrade to that version.
|
let url: string | null = `${baseUrl}?page_size=100`;
|
||||||
return DEFAULT_UPDATE_DATA;
|
let allResults: { digest: string; name: string }[] = [];
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
|
// Fetch all tags from Docker Hub
|
||||||
let url: string | null = `${baseUrl}?page_size=100`;
|
while (url) {
|
||||||
let allResults: { digest: string; name: string }[] = [];
|
const response = await fetch(url, {
|
||||||
while (url) {
|
method: "GET",
|
||||||
const response = await fetch(url, {
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "GET",
|
});
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
next: string | null;
|
next: string | null;
|
||||||
results: { digest: string; name: string }[];
|
results: { digest: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
allResults = allResults.concat(data.results);
|
allResults = allResults.concat(data.results);
|
||||||
url = data?.next;
|
url = data?.next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageTag = getDokployImageTag();
|
const currentImageTag = getDokployImageTag();
|
||||||
const searchedDigest = allResults.find((t) => t.name === imageTag)?.digest;
|
|
||||||
|
|
||||||
if (!searchedDigest) {
|
// Special handling for canary and feature branches
|
||||||
return DEFAULT_UPDATE_DATA;
|
// For development versions (canary/feature), don't perform update checks
|
||||||
}
|
// These are unstable versions that change frequently, and users on these
|
||||||
|
// branches are expected to manually manage updates
|
||||||
|
if (currentImageTag === "canary" || currentImageTag === "feature") {
|
||||||
|
const currentDigest = await getServiceImageDigest();
|
||||||
|
const latestDigest = allResults.find(
|
||||||
|
(t) => t.name === currentImageTag,
|
||||||
|
)?.digest;
|
||||||
|
if (!latestDigest) {
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
|
}
|
||||||
|
if (currentDigest !== latestDigest) {
|
||||||
|
return {
|
||||||
|
latestVersion: currentImageTag,
|
||||||
|
updateAvailable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
latestVersion: currentImageTag,
|
||||||
|
updateAvailable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (imageTag === "latest") {
|
// For stable versions, use semver comparison
|
||||||
const versionedTag = allResults.find(
|
// Find the "latest" tag and get its digest
|
||||||
(t) => t.digest === searchedDigest && t.name.startsWith("v"),
|
const latestTag = allResults.find((t) => t.name === "latest");
|
||||||
);
|
|
||||||
|
|
||||||
if (!versionedTag) {
|
if (!latestTag) {
|
||||||
return DEFAULT_UPDATE_DATA;
|
return DEFAULT_UPDATE_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name: latestVersion, digest } = versionedTag;
|
// Find the versioned tag (v0.x.x) that has the same digest as "latest"
|
||||||
const updateAvailable = digest !== currentDigest;
|
const latestVersionTag = allResults.find(
|
||||||
|
(t) => t.digest === latestTag.digest && t.name.startsWith("v"),
|
||||||
|
);
|
||||||
|
|
||||||
return { latestVersion, updateAvailable };
|
if (!latestVersionTag) {
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestVersion = latestVersionTag.name;
|
||||||
|
|
||||||
|
// Use semver to compare versions for stable releases
|
||||||
|
const cleanedCurrent = semver.clean(currentVersion);
|
||||||
|
const cleanedLatest = semver.clean(latestVersion);
|
||||||
|
|
||||||
|
if (!cleanedCurrent || !cleanedLatest) {
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the latest version is greater than the current version
|
||||||
|
const updateAvailable = semver.gt(cleanedLatest, cleanedCurrent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
latestVersion,
|
||||||
|
updateAvailable,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching update data:", error);
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
}
|
}
|
||||||
const updateAvailable = searchedDigest !== currentDigest;
|
|
||||||
return { latestVersion: imageTag, updateAvailable };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TreeDataItem {
|
interface TreeDataItem {
|
||||||
@@ -254,11 +293,22 @@ fi`;
|
|||||||
export const reloadDockerResource = async (
|
export const reloadDockerResource = async (
|
||||||
resourceName: string,
|
resourceName: string,
|
||||||
serverId?: string,
|
serverId?: string,
|
||||||
|
version?: string,
|
||||||
) => {
|
) => {
|
||||||
const resourceType = await getDockerResourceType(resourceName, serverId);
|
const resourceType = await getDockerResourceType(resourceName, serverId);
|
||||||
let command = "";
|
let command = "";
|
||||||
if (resourceType === "service") {
|
if (resourceType === "service") {
|
||||||
command = `docker service update --force ${resourceName}`;
|
if (resourceName === "dokploy") {
|
||||||
|
const currentImageTag = getDokployImageTag();
|
||||||
|
let imageTag = version;
|
||||||
|
if (currentImageTag === "canary" || currentImageTag === "feature") {
|
||||||
|
imageTag = currentImageTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
command = `docker service update --force --image dokploy/dokploy:${imageTag} ${resourceName}`;
|
||||||
|
} else {
|
||||||
|
command = `docker service update --force ${resourceName}`;
|
||||||
|
}
|
||||||
} else if (resourceType === "standalone") {
|
} else if (resourceType === "standalone") {
|
||||||
command = `docker restart ${resourceName}`;
|
command = `docker restart ${resourceName}`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { paths } from "@dokploy/server/constants";
|
import { IS_CLOUD, paths } from "@dokploy/server/constants";
|
||||||
|
import { getDokployUrl } from "@dokploy/server/services/admin";
|
||||||
import {
|
import {
|
||||||
createServerDeployment,
|
createServerDeployment,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
} from "@dokploy/server/services/deployment";
|
} from "@dokploy/server/services/deployment";
|
||||||
import { findServerById } from "@dokploy/server/services/server";
|
import {
|
||||||
|
findServerById,
|
||||||
|
updateServerById,
|
||||||
|
} from "@dokploy/server/services/server";
|
||||||
import {
|
import {
|
||||||
getDefaultMiddlewares,
|
getDefaultMiddlewares,
|
||||||
getDefaultServerTraefikConfig,
|
getDefaultServerTraefikConfig,
|
||||||
@@ -16,6 +20,15 @@ import {
|
|||||||
import slug from "slugify";
|
import slug from "slugify";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import { recreateDirectory } from "../utils/filesystem/directory";
|
import { recreateDirectory } from "../utils/filesystem/directory";
|
||||||
|
import { setupMonitoring } from "./monitoring-setup";
|
||||||
|
|
||||||
|
const generateToken = () => {
|
||||||
|
const array = new Uint8Array(64);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const slugify = (text: string | undefined) => {
|
export const slugify = (text: string | undefined) => {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -59,6 +72,29 @@ export const serverSetup = async (
|
|||||||
);
|
);
|
||||||
await installRequirements(serverId, onData);
|
await installRequirements(serverId, onData);
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
onData?.("\nConfiguring Monitoring: 🔄\n");
|
||||||
|
|
||||||
|
const baseUrl = await getDokployUrl();
|
||||||
|
const token = generateToken();
|
||||||
|
const urlCallback = `${baseUrl}/api/trpc/notification.receiveNotification`;
|
||||||
|
|
||||||
|
// Update server with monitoring configuration
|
||||||
|
await updateServerById(serverId, {
|
||||||
|
metricsConfig: {
|
||||||
|
server: {
|
||||||
|
...server.metricsConfig.server,
|
||||||
|
token: token,
|
||||||
|
urlCallback: urlCallback,
|
||||||
|
},
|
||||||
|
containers: server.metricsConfig.containers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupMonitoring(serverId);
|
||||||
|
onData?.("\nMonitoring Configured: ✅\n");
|
||||||
|
}
|
||||||
|
|
||||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
|
|
||||||
onData?.("\nSetup Server: ✅\n");
|
onData?.("\nSetup Server: ✅\n");
|
||||||
@@ -629,7 +665,7 @@ const installNixpacks = () => `
|
|||||||
if command_exists nixpacks; then
|
if command_exists nixpacks; then
|
||||||
echo "Nixpacks already installed ✅"
|
echo "Nixpacks already installed ✅"
|
||||||
else
|
else
|
||||||
export NIXPACKS_VERSION=1.39.0
|
export NIXPACKS_VERSION=1.41.0
|
||||||
bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
|
bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
|
||||||
echo "Nixpacks version $NIXPACKS_VERSION installed ✅"
|
echo "Nixpacks version $NIXPACKS_VERSION installed ✅"
|
||||||
fi
|
fi
|
||||||
@@ -639,7 +675,7 @@ const installRailpack = () => `
|
|||||||
if command_exists railpack; then
|
if command_exists railpack; then
|
||||||
echo "Railpack already installed ✅"
|
echo "Railpack already installed ✅"
|
||||||
else
|
else
|
||||||
export RAILPACK_VERSION=0.2.2
|
export RAILPACK_VERSION=0.15.4
|
||||||
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
|
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
|
||||||
echo "Railpack version $RAILPACK_VERSION installed ✅"
|
echo "Railpack version $RAILPACK_VERSION installed ✅"
|
||||||
fi
|
fi
|
||||||
@@ -653,8 +689,8 @@ const installBuildpacks = () => `
|
|||||||
if command_exists pack; then
|
if command_exists pack; then
|
||||||
echo "Buildpacks already installed ✅"
|
echo "Buildpacks already installed ✅"
|
||||||
else
|
else
|
||||||
BUILDPACKS_VERSION=0.35.0
|
BUILDPACKS_VERSION=0.39.1
|
||||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v$BUILDPACKS_VERSION-linux$SUFFIX.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v$BUILDPACKS_VERSION-linux$SUFFIX.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||||
echo "Buildpacks version $BUILDPACKS_VERSION installed ✅"
|
echo "Buildpacks version $BUILDPACKS_VERSION installed ✅"
|
||||||
fi
|
fi
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
|||||||
return createOpenAICompatible({
|
return createOpenAICompatible({
|
||||||
name: "gemini",
|
name: "gemini",
|
||||||
baseURL: config.apiUrl,
|
baseURL: config.apiUrl,
|
||||||
queryParams: { key: config.apiKey },
|
headers: {
|
||||||
headers: {},
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
case "custom":
|
case "custom":
|
||||||
return createOpenAICompatible({
|
return createOpenAICompatible({
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ const getExportEnvCommand = (compose: ComposeNested) => {
|
|||||||
const envVars = getEnviromentVariablesObject(
|
const envVars = getEnviromentVariablesObject(
|
||||||
compose.env,
|
compose.env,
|
||||||
compose.environment.project.env,
|
compose.environment.project.env,
|
||||||
|
compose.environment.env,
|
||||||
);
|
);
|
||||||
const exports = Object.entries(envVars)
|
const exports = Object.entries(envVars)
|
||||||
.map(([key, value]) => `${key}=${quote([value])}`)
|
.map(([key, value]) => `${key}=${quote([value])}`)
|
||||||
|
|||||||
Generated
+26
-718
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -276,7 +276,7 @@ table application {
|
|||||||
replicas integer [not null, default: 1]
|
replicas integer [not null, default: 1]
|
||||||
applicationStatus applicationStatus [not null, default: 'idle']
|
applicationStatus applicationStatus [not null, default: 'idle']
|
||||||
buildType buildType [not null, default: 'nixpacks']
|
buildType buildType [not null, default: 'nixpacks']
|
||||||
railpackVersion text [default: '0.2.2']
|
railpackVersion text [default: '0.15.4']
|
||||||
herokuVersion text [default: '24']
|
herokuVersion text [default: '24']
|
||||||
publishDirectory text
|
publishDirectory text
|
||||||
isStaticSpa boolean
|
isStaticSpa boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user