mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 12:45:21 +02:00
Compare commits
2 Commits
ulimits
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c96d8559a | ||
|
|
39cf706053 |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -8,7 +8,7 @@ Before submitting this PR, please make sure that:
|
||||
|
||||
- [ ] You created a dedicated branch based on the `canary` branch.
|
||||
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||
- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.
|
||||
- [ ] You have tested this PR in your local instance.
|
||||
|
||||
## Issues related (if applicable)
|
||||
|
||||
|
||||
6
.github/workflows/pull-request.yml
vendored
6
.github/workflows/pull-request.yml
vendored
@@ -24,14 +24,14 @@ jobs:
|
||||
- name: Install Nixpacks
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export NIXPACKS_VERSION=1.41.0
|
||||
export NIXPACKS_VERSION=1.39.0
|
||||
curl -sSL https://nixpacks.com/install.sh | bash
|
||||
echo "Nixpacks installed $NIXPACKS_VERSION"
|
||||
|
||||
|
||||
- name: Install Railpack
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export RAILPACK_VERSION=0.15.4
|
||||
export RAILPACK_VERSION=0.15.0
|
||||
curl -sSL https://railpack.com/install.sh | bash
|
||||
echo "Railpack installed $RAILPACK_VERSION"
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -43,7 +43,4 @@ yarn-error.log*
|
||||
*.pem
|
||||
|
||||
|
||||
.db
|
||||
|
||||
# Development environment
|
||||
.devcontainer
|
||||
.db
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
|
||||
|
||||
Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues.
|
||||
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
|
||||
|
||||
We have a few guidelines to follow when contributing to this project:
|
||||
|
||||
@@ -11,7 +11,6 @@ We have a few guidelines to follow when contributing to this project:
|
||||
- [Development](#development)
|
||||
- [Build](#build)
|
||||
- [Pull Request](#pull-request)
|
||||
- [Important Considerations](#important-considerations-for-pull-requests)
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -149,7 +148,7 @@ curl -sSL https://railpack.com/install.sh | sh
|
||||
|
||||
```bash
|
||||
# Install Buildpacks
|
||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||
```
|
||||
|
||||
## Pull Request
|
||||
@@ -163,9 +162,8 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
|
||||
- If your pull request fixes an open issue, please reference the issue in the pull request description.
|
||||
- Once your pull request is merged, you will be automatically added as a contributor to the project.
|
||||
|
||||
### Important Considerations for Pull Requests
|
||||
**Important Considerations for Pull Requests:**
|
||||
|
||||
- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code.
|
||||
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
|
||||
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
|
||||
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -51,22 +51,18 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --ver
|
||||
# Install Nixpacks and tsx
|
||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||
|
||||
ARG NIXPACKS_VERSION=1.41.0
|
||||
ARG NIXPACKS_VERSION=1.39.0
|
||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||
&& chmod +x install.sh \
|
||||
&& ./install.sh \
|
||||
&& pnpm install -g tsx
|
||||
|
||||
# Install Railpack
|
||||
ARG RAILPACK_VERSION=0.15.4
|
||||
ARG RAILPACK_VERSION=0.2.2
|
||||
RUN curl -sSL https://railpack.com/install.sh | bash
|
||||
|
||||
# Install buildpacks
|
||||
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||
CMD [ "pnpm", "start" ]
|
||||
|
||||
@@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash
|
||||
RUN pnpm install -g tsx
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "start" ]
|
||||
CMD [ "pnpm", "start" ]
|
||||
@@ -35,5 +35,4 @@ COPY --from=build /prod/schedules/dist ./dist
|
||||
COPY --from=build /prod/schedules/package.json ./package.json
|
||||
COPY --from=build /prod/schedules/node_modules ./node_modules
|
||||
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
CMD ["pnpm", "start"]
|
||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||
@@ -35,5 +35,4 @@ COPY --from=build /prod/api/dist ./dist
|
||||
COPY --from=build /prod/api/package.json ./package.json
|
||||
COPY --from=build /prod/api/node_modules ./node_modules
|
||||
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
CMD ["pnpm", "start"]
|
||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||
19
LICENSE.MD
19
LICENSE.MD
@@ -1,13 +1,8 @@
|
||||
Copyright 2026-present Dokploy Technology, Inc.
|
||||
# License
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
## Core License (Apache License 2.0)
|
||||
|
||||
* All content that resides under a "/proprietary" directory of this repository, if that directory exists, is licensed under the license defined in "LICENSE_PROPRIETARY".
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the "Apache License 2.0" license as defined below.
|
||||
|
||||
## Apache License 2.0
|
||||
|
||||
Copyright 2026-present Dokploy Technology, Inc.
|
||||
Copyright 2025 Mauricio Siu.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -20,4 +15,12 @@ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
## Additional Terms for Specific Features
|
||||
|
||||
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||
|
||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||
|
||||
For further inquiries or permissions, please contact us directly.
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
The Dokploy Source Available license (DSAL) version 1.0
|
||||
|
||||
Copyright (c) 2026-present Dokploy Technology, Inc.
|
||||
|
||||
With regard to the Dokploy Software:This software and associated documentation files (the "Software") may only beused in production, if you (and any entity that you represent) have agreed to, and are in compliance with, a valid commercial agreement from Dokploy.Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Dokploy Source Available License. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to theforegoing, it is forbidden to copy, merge, publish, distribute, sublicense,and/or sell the Software.
|
||||
|
||||
This Dokploy Source Available license applies only to the part of this Software that is in a /proprietary folder. The full text of this License shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Dokploy Software, thosecomponents are licensed under the original license provided by the owner of the applicable component.
|
||||
79
README.md
79
README.md
@@ -12,8 +12,24 @@
|
||||
<br />
|
||||
|
||||
|
||||
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://tuple.app/dokploy">
|
||||
<img src=".github/sponsors/tuple.png" alt="Tuple's sponsorship image" width="400"/>
|
||||
</a>
|
||||
|
||||
### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy)
|
||||
[Available for MacOS & Windows](https://tuple.app/dokploy)<br>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
||||
|
||||
|
||||
## ✨ Features
|
||||
|
||||
Dokploy includes multiple features to make your life easier.
|
||||
@@ -44,9 +60,72 @@ curl -sSL https://dokploy.com/install.sh | sh
|
||||
|
||||
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
|
||||
## ♥️ Sponsors
|
||||
|
||||
🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features.
|
||||
|
||||
[Dokploy Open Collective](https://opencollective.com/dokploy)
|
||||
|
||||
[Github Sponsors](https://github.com/sponsors/Siumauricio)
|
||||
|
||||
<!-- Hero Sponsors 🎖 -->
|
||||
|
||||
<!-- Add Hero Sponsors here -->
|
||||
|
||||
### Hero Sponsors 🎖
|
||||
|
||||
<div>
|
||||
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
|
||||
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
|
||||
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
|
||||
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
|
||||
</a>
|
||||
<a href="https://awesome.tools/" target="_blank">
|
||||
<img src=".github/sponsors/awesome.png" width="200" height="150" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 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 🤝
|
||||
|
||||
#### Organizations:
|
||||
|
||||
[Sponsors on Open Collective](https://opencollective.com/dokploy)
|
||||
|
||||
#### Individuals:
|
||||
|
||||
[](https://opencollective.com/dokploy)
|
||||
|
||||
### Contributors 🤝
|
||||
|
||||
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/zod-validator": "0.3.0",
|
||||
"@nerimity/mimiqueue": "1.2.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.11.7",
|
||||
"hono": "^4.7.10",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"react": "18.2.0",
|
||||
@@ -23,7 +24,7 @@
|
||||
"zod": "^3.25.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/node": "^20.17.51",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"tsx": "^4.16.2",
|
||||
|
||||
@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
||||
titleLog: z.string().optional(),
|
||||
descriptionLog: z.string().optional(),
|
||||
server: z.boolean().optional(),
|
||||
type: z.enum(["deploy", "redeploy"]),
|
||||
type: z.enum(["deploy"]),
|
||||
applicationType: z.literal("application-preview"),
|
||||
serverId: z.string().min(1),
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
deployPreviewApplication,
|
||||
rebuildApplication,
|
||||
rebuildCompose,
|
||||
rebuildPreviewApplication,
|
||||
updateApplicationStatus,
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
@@ -55,14 +54,7 @@ export const deploy = async (job: DeployJob) => {
|
||||
previewStatus: "running",
|
||||
});
|
||||
if (job.server) {
|
||||
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") {
|
||||
if (job.type === "deploy") {
|
||||
await deployPreviewApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Preview Deployment",
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
@@ -4,30 +4,21 @@ import { describe, expect, it } from "vitest";
|
||||
describe("addDokployNetworkToService", () => {
|
||||
it("should add network to an empty array", () => {
|
||||
const result = addDokployNetworkToService([]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
});
|
||||
|
||||
it("should not add duplicate network to an array", () => {
|
||||
const result = addDokployNetworkToService(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
});
|
||||
|
||||
it("should add network to an existing array with other networks", () => {
|
||||
const result = addDokployNetworkToService(["other-network"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network", "default"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network"]);
|
||||
});
|
||||
|
||||
it("should add network to an object if networks is an object", () => {
|
||||
const result = addDokployNetworkToService({ "other-network": {} });
|
||||
expect(result).toEqual({
|
||||
"other-network": {},
|
||||
"dokploy-network": {},
|
||||
default: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not duplicate default network when already present", () => {
|
||||
const result = addDokployNetworkToService(["default", "dokploy-network"]);
|
||||
expect(result).toEqual(["default", "dokploy-network"]);
|
||||
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,11 +25,10 @@ if (typeof window === "undefined") {
|
||||
}
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
railpackVersion: "0.15.4",
|
||||
railpackVersion: "0.2.2",
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
createEnvFile: true,
|
||||
bitbucketRepositorySlug: "",
|
||||
herokuVersion: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Type definitions matching the project structure
|
||||
type Environment = {
|
||||
environmentId: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
type Project = {
|
||||
projectId: string;
|
||||
name: string;
|
||||
environments: Environment[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function that selects the appropriate environment for a user
|
||||
* This matches the logic used in search-command.tsx and show.tsx
|
||||
*/
|
||||
function selectAccessibleEnvironment(
|
||||
project: Project | null | undefined,
|
||||
): Environment | null {
|
||||
if (!project || !project.environments || project.environments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||
const defaultEnvironment =
|
||||
project.environments.find((environment) => environment.isDefault) ||
|
||||
project.environments[0];
|
||||
|
||||
return defaultEnvironment || null;
|
||||
}
|
||||
|
||||
describe("Environment Access Fallback", () => {
|
||||
describe("selectAccessibleEnvironment", () => {
|
||||
it("should return default environment when user has access to it", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-prod",
|
||||
name: "production",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-prod");
|
||||
expect(result?.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("should return first accessible environment when user doesn't have access to default", () => {
|
||||
// Simulating filtered environments (user only has access to development)
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
// Note: production is not in the list because user doesn't have access
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
environmentId: "env-staging",
|
||||
name: "staging",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-dev");
|
||||
expect(result?.name).toBe("development");
|
||||
});
|
||||
|
||||
it("should return first environment when no default is marked but environments exist", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
environmentId: "env-staging",
|
||||
name: "staging",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-dev");
|
||||
});
|
||||
|
||||
it("should return null when project has no accessible environments", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when project is null", () => {
|
||||
const result = selectAccessibleEnvironment(null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when project is undefined", () => {
|
||||
const result = selectAccessibleEnvironment(undefined);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle project with single accessible environment", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-dev");
|
||||
});
|
||||
|
||||
it("should prioritize default environment even when it's not first in the array", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
environmentId: "env-staging",
|
||||
name: "staging",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
environmentId: "env-prod",
|
||||
name: "production",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-prod");
|
||||
expect(result?.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple default environments by returning the first one found", () => {
|
||||
// Edge case: multiple environments marked as default (shouldn't happen, but test it)
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-prod-1",
|
||||
name: "production-1",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
environmentId: "env-prod-2",
|
||||
name: "production-2",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.isDefault).toBe(true);
|
||||
// Should return the first default found
|
||||
expect(result?.environmentId).toBe("env-prod-1");
|
||||
});
|
||||
|
||||
it("should work correctly when user has access to multiple environments including default", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-prod",
|
||||
name: "production",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
environmentId: "env-staging",
|
||||
name: "staging",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-prod");
|
||||
expect(result?.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle real-world scenario: user with only development access", () => {
|
||||
// This simulates the exact bug we're fixing:
|
||||
// User has access to development but not production (default)
|
||||
// The filtered environments array only contains development
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "My Project",
|
||||
environments: [
|
||||
// Only development is accessible (production was filtered out)
|
||||
{
|
||||
environmentId: "env-dev-123",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-dev-123");
|
||||
expect(result?.name).toBe("development");
|
||||
// Should not be null even though it's not the default
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment selection edge cases", () => {
|
||||
it("should handle project with environments property as undefined", () => {
|
||||
const project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: undefined,
|
||||
} as unknown as Project;
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle project with null environments array", () => {
|
||||
const project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: null,
|
||||
} as unknown as Project;
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
184
apps/dokploy/__test__/env/stack-environment.test.ts
vendored
184
apps/dokploy/__test__/env/stack-environment.test.ts
vendored
@@ -1,184 +0,0 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,11 @@ type MockCreateServiceOptions = {
|
||||
|
||||
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
|
||||
vi.hoisted(() => {
|
||||
const inspect = vi.fn<() => Promise<never>>();
|
||||
const inspect = vi.fn<[], Promise<never>>();
|
||||
const getService = vi.fn(() => ({ inspect }));
|
||||
const createService = vi.fn<
|
||||
(opts: MockCreateServiceOptions) => Promise<void>
|
||||
>(async () => undefined);
|
||||
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
|
||||
async () => undefined,
|
||||
);
|
||||
const getRemoteDocker = vi.fn(async () => ({
|
||||
getService,
|
||||
createService,
|
||||
@@ -80,9 +80,7 @@ describe("mechanizeDockerContainer", () => {
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
@@ -99,9 +97,7 @@ describe("mechanizeDockerContainer", () => {
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Mock the DB module so tests that import from @dokploy/server (barrel)
|
||||
* never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs).
|
||||
* Without this, loading the server barrel pulls in lib/auth and db, which
|
||||
* connect to localhost:5432 and cause ECONNREFUSED.
|
||||
*/
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const chain = () => chain;
|
||||
chain.set = () => chain;
|
||||
chain.where = () => chain;
|
||||
chain.values = () => chain;
|
||||
chain.returning = () => Promise.resolve([{}]);
|
||||
chain.then = undefined;
|
||||
|
||||
const tableMock = {
|
||||
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
insert: vi.fn(() => Promise.resolve([{}])),
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
};
|
||||
const createQueryMock = () => tableMock;
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(() => chain),
|
||||
insert: vi.fn(() => ({
|
||||
values: () => ({ returning: () => Promise.resolve([{}]) }),
|
||||
})),
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
query: new Proxy({} as Record<string, typeof tableMock>, {
|
||||
get: () => tableMock,
|
||||
}),
|
||||
},
|
||||
dbUrl: "postgres://mock:mock@localhost:5432/mock",
|
||||
};
|
||||
});
|
||||
@@ -5,27 +5,21 @@ vi.mock("node:fs", () => ({
|
||||
default: fs,
|
||||
}));
|
||||
|
||||
import type { FileConfig } from "@dokploy/server";
|
||||
import type { FileConfig, User } from "@dokploy/server";
|
||||
import {
|
||||
createDefaultServerTraefikConfig,
|
||||
loadOrCreateConfig,
|
||||
updateServerTraefik,
|
||||
} from "@dokploy/server";
|
||||
import type { webServerSettings } from "@dokploy/server/db/schema";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
type WebServerSettings = typeof webServerSettings.$inferSelect;
|
||||
|
||||
const baseSettings: WebServerSettings = {
|
||||
id: "",
|
||||
const baseAdmin: User = {
|
||||
https: false,
|
||||
certificateType: "none",
|
||||
host: null,
|
||||
serverIp: null,
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
logCleanupCron: null,
|
||||
enablePaidFeatures: false,
|
||||
allowImpersonation: false,
|
||||
role: "user",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
metricsConfig: {
|
||||
containers: {
|
||||
refreshRate: 20,
|
||||
@@ -51,8 +45,29 @@ const baseSettings: WebServerSettings = {
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
cleanupCacheOnPreviews: false,
|
||||
createdAt: null,
|
||||
createdAt: new Date(),
|
||||
serverIp: null,
|
||||
certificateType: "none",
|
||||
host: null,
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
logCleanupCron: null,
|
||||
serversQuantity: 0,
|
||||
stripeCustomerId: "",
|
||||
stripeSubscriptionId: "",
|
||||
banExpires: new Date(),
|
||||
banned: true,
|
||||
banReason: "",
|
||||
email: "",
|
||||
expirationDate: "",
|
||||
id: "",
|
||||
isRegistered: false,
|
||||
createdAt2: new Date().toISOString(),
|
||||
emailVerified: false,
|
||||
image: "",
|
||||
updatedAt: new Date(),
|
||||
twoFactorEnabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -70,7 +85,7 @@ test("Should read the configuration file", () => {
|
||||
test("Should apply redirect-to-https", () => {
|
||||
updateServerTraefik(
|
||||
{
|
||||
...baseSettings,
|
||||
...baseAdmin,
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
@@ -85,7 +100,7 @@ test("Should apply redirect-to-https", () => {
|
||||
});
|
||||
|
||||
test("Should change only host when no certificate", () => {
|
||||
updateServerTraefik(baseSettings, "example.com");
|
||||
updateServerTraefik(baseAdmin, "example.com");
|
||||
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
@@ -95,7 +110,7 @@ test("Should change only host when no certificate", () => {
|
||||
test("Should not touch config without host", () => {
|
||||
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
updateServerTraefik(baseSettings, null);
|
||||
updateServerTraefik(baseAdmin, null);
|
||||
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
@@ -104,14 +119,11 @@ test("Should not touch config without host", () => {
|
||||
|
||||
test("Should remove websecure if https rollback to http", () => {
|
||||
updateServerTraefik(
|
||||
{ ...baseSettings, certificateType: "letsencrypt" },
|
||||
{ ...baseAdmin, certificateType: "letsencrypt" },
|
||||
"example.com",
|
||||
);
|
||||
|
||||
updateServerTraefik(
|
||||
{ ...baseSettings, certificateType: "none" },
|
||||
"example.com",
|
||||
);
|
||||
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
|
||||
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@ import { createRouterConfig } from "@dokploy/server";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
railpackVersion: "0.15.4",
|
||||
railpackVersion: "0.2.2",
|
||||
rollbackActive: false,
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
createEnvFile: true,
|
||||
bitbucketRepositorySlug: "",
|
||||
herokuVersion: "",
|
||||
giteaRepository: "",
|
||||
giteaOwner: "",
|
||||
|
||||
@@ -7,15 +7,10 @@ export default defineConfig({
|
||||
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
|
||||
pool: "forks",
|
||||
setupFiles: [path.resolve(__dirname, "setup.ts")],
|
||||
},
|
||||
define: {
|
||||
"process.env": {
|
||||
NODE: "test",
|
||||
GITHUB_CLIENT_ID: "test",
|
||||
GITHUB_CLIENT_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,154 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
@@ -1,270 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { 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 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,
|
||||
},
|
||||
});
|
||||
|
||||
const testCommands = form.watch("Test") || [];
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}, [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 = () => {
|
||||
form.setValue("Test", [...testCommands, ""]);
|
||||
};
|
||||
|
||||
const updateTestCommand = (index: number, value: string) => {
|
||||
const newCommands = [...testCommands];
|
||||
newCommands[index] = value;
|
||||
form.setValue("Test", newCommands);
|
||||
};
|
||||
|
||||
const removeTestCommand = (index: number) => {
|
||||
form.setValue(
|
||||
"Test",
|
||||
testCommands.filter((_: string, i: number) => 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: string, index: number) => (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
export { EndpointSpecForm } from "./endpoint-spec-form";
|
||||
export { HealthCheckForm } from "./health-check-form";
|
||||
export { LabelsForm } from "./labels-form";
|
||||
export { ModeForm } from "./mode-form";
|
||||
export { NetworkForm } from "./network-form";
|
||||
export { PlacementForm } from "./placement-form";
|
||||
export { RestartPolicyForm } from "./restart-policy-form";
|
||||
export { RollbackConfigForm } from "./rollback-config-form";
|
||||
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||
export { UpdateConfigForm } from "./update-config-form";
|
||||
export { filterEmptyValues, hasValues } from "./utils";
|
||||
@@ -1,200 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,195 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,313 +0,0 @@
|
||||
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";
|
||||
|
||||
const driverOptEntrySchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const networkFormSchema = z.object({
|
||||
networks: z
|
||||
.array(
|
||||
z.object({
|
||||
Target: z.string().optional(),
|
||||
Aliases: z.string().optional(),
|
||||
DriverOptsEntries: z.array(driverOptEntrySchema).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface NetworkFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
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<z.infer<typeof networkFormSchema>>({
|
||||
resolver: zodResolver(networkFormSchema),
|
||||
defaultValues: {
|
||||
networks: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "networks",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.networkSwarm && Array.isArray(data.networkSwarm)) {
|
||||
const networkEntries = data.networkSwarm.map((network) => ({
|
||||
Target: network.Target || "",
|
||||
Aliases: network.Aliases?.join(", ") || "",
|
||||
DriverOptsEntries: network.DriverOpts
|
||||
? Object.entries(network.DriverOpts).map(([key, value]) => ({
|
||||
key,
|
||||
value: value ?? "",
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
form.reset({ networks: networkEntries });
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: z.infer<typeof networkFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const networksArray =
|
||||
formData.networks
|
||||
?.filter((network) => network.Target)
|
||||
.map((network) => {
|
||||
const entries = (network.DriverOptsEntries ?? []).filter(
|
||||
(e) => e.key.trim() !== "",
|
||||
);
|
||||
const driverOpts =
|
||||
entries.length > 0
|
||||
? Object.fromEntries(
|
||||
entries.map((e) => [e.key.trim(), e.value]),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
Target: network.Target,
|
||||
Aliases: network.Aliases
|
||||
? network.Aliases.split(",").map((alias) => alias.trim())
|
||||
: undefined,
|
||||
DriverOpts: driverOpts,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
// If no networks, send null to clear the database
|
||||
const networksToSend = networksArray.length > 0 ? networksArray : null;
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
networkSwarm: networksToSend,
|
||||
});
|
||||
|
||||
toast.success("Network configuration updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating network configuration");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel>Networks</FormLabel>
|
||||
<FormDescription>
|
||||
Configure network attachments for your service
|
||||
</FormDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="space-y-2 p-3 border rounded">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Target`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Network Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="my-network" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name of the network to attach to
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Aliases`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Aliases (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="alias1, alias2, alias3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated list of network aliases
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Driver options (optional)</FormLabel>
|
||||
<FormDescription>
|
||||
e.g. com.docker.network.driver.mtu,
|
||||
com.docker.network.driver.host_binding
|
||||
</FormDescription>
|
||||
{(
|
||||
form.watch(`networks.${index}.DriverOptsEntries`) ?? []
|
||||
).map((_, optIndex) => (
|
||||
<div
|
||||
key={optIndex}
|
||||
className="flex gap-2 items-end flex-wrap"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[140px]">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="com.docker.network.driver.mtu"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[100px]">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="1500" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
) ?? [];
|
||||
form.setValue(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
entries.filter((_, i) => i !== optIndex),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(`networks.${index}.DriverOptsEntries`) ??
|
||||
[];
|
||||
form.setValue(`networks.${index}.DriverOptsEntries`, [
|
||||
...entries,
|
||||
{ key: "", value: "" },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add driver option
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove Network
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({
|
||||
Target: "",
|
||||
Aliases: "",
|
||||
DriverOptsEntries: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Network
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({ networks: [] });
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Networks
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,347 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { 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({
|
||||
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,
|
||||
Preferences: formData.Preferences?.map((p) => ({
|
||||
Spread: { SpreadDescriptor: p.SpreadDescriptor },
|
||||
})),
|
||||
}
|
||||
: 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>
|
||||
);
|
||||
};
|
||||
@@ -1,219 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
@@ -1,257 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
@@ -1,158 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,264 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const UpdateTraefikConfigSchema = z.object({
|
||||
@@ -61,7 +59,6 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
||||
|
||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -88,15 +85,13 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
}, [data]);
|
||||
|
||||
const onSubmit = async (data: UpdateTraefikConfig) => {
|
||||
if (!skipYamlValidation) {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
@@ -121,7 +116,6 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setSkipYamlValidation(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -175,28 +169,7 @@ routers:
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-4">
|
||||
<div className="flex flex-col gap-1 w-full sm:w-auto sm:mr-auto">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="skip-yaml-validation-app"
|
||||
checked={skipYamlValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipYamlValidation(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-yaml-validation-app"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Skip YAML validation (for Go templating)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check to save configs with Go templating (e.g.{" "}
|
||||
<code className="text-xs">{"{{range}}"}</code>).
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-traefik-config"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Cog } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -20,39 +20,8 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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 {
|
||||
dockerfile = "dockerfile",
|
||||
heroku_buildpacks = "heroku_buildpacks",
|
||||
@@ -96,7 +65,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal(BuildType.railpack),
|
||||
railpackVersion: z.string().nullable().default("0.15.4"),
|
||||
railpackVersion: z.string().nullable().default("0.2.2"),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal(BuildType.static),
|
||||
@@ -183,8 +152,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
});
|
||||
|
||||
const buildType = form.watch("buildType");
|
||||
const railpackVersion = form.watch("railpackVersion");
|
||||
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -196,22 +163,9 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
};
|
||||
|
||||
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]);
|
||||
|
||||
// Hide builder section when Docker provider is selected
|
||||
if (data?.sourceType === "docker") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = async (data: AddTemplate) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
@@ -232,7 +186,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
||||
railpackVersion:
|
||||
data.buildType === BuildType.railpack
|
||||
? data.railpackVersion || "0.15.4"
|
||||
? data.railpackVersion || "0.2.2"
|
||||
: null,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -449,88 +403,23 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
/>
|
||||
)}
|
||||
{buildType === BuildType.railpack && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="railpackVersion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Railpack Version</FormLabel>
|
||||
<FormControl>
|
||||
{isManualRailpackVersion ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Enter custom version (e.g., 0.15.4)"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="railpackVersion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Railpack Version</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Railpack Version"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
|
||||
@@ -256,9 +256,9 @@ export const ShowDeployments = ({
|
||||
return (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{index + 1}. {deployment.status}
|
||||
<StatusTooltip
|
||||
@@ -313,8 +313,8 @@ export const ShowDeployments = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
|
||||
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
|
||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
{deployment.startedAt && deployment.finishedAt && (
|
||||
<Badge
|
||||
@@ -333,7 +333,7 @@ export const ShowDeployments = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{deployment.pid && deployment.status === "running" && (
|
||||
<DialogAction
|
||||
title="Kill Process"
|
||||
@@ -355,7 +355,6 @@ export const ShowDeployments = ({
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isKillingProcess}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Kill Process
|
||||
</Button>
|
||||
@@ -365,7 +364,6 @@ export const ShowDeployments = ({
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
@@ -407,7 +405,6 @@ export const ShowDeployments = ({
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isLoading={isRollingBack}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||
Rollback
|
||||
|
||||
@@ -54,7 +54,6 @@ const BitbucketProviderSchema = z.object({
|
||||
.object({
|
||||
repo: z.string().min(1, "Repo is required"),
|
||||
owner: z.string().min(1, "Owner is required"),
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
@@ -83,7 +82,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
repository: {
|
||||
owner: "",
|
||||
repo: "",
|
||||
slug: "",
|
||||
},
|
||||
bitbucketId: "",
|
||||
branch: "",
|
||||
@@ -116,14 +114,11 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
} = api.bitbucket.getBitbucketBranches.useQuery(
|
||||
{
|
||||
owner: repository?.owner,
|
||||
repo: repository?.slug || repository?.repo || "",
|
||||
repo: repository?.repo,
|
||||
bitbucketId,
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
!!repository?.owner &&
|
||||
!!(repository?.slug || repository?.repo) &&
|
||||
!!bitbucketId,
|
||||
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -134,7 +129,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
repository: {
|
||||
repo: data.bitbucketRepository || "",
|
||||
owner: data.bitbucketOwner || "",
|
||||
slug: data.bitbucketRepositorySlug || "",
|
||||
},
|
||||
buildPath: data.bitbucketBuildPath || "/",
|
||||
bitbucketId: data.bitbucketId || "",
|
||||
@@ -148,7 +142,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
await mutateAsync({
|
||||
bitbucketBranch: data.branch,
|
||||
bitbucketRepository: data.repository.repo,
|
||||
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
|
||||
bitbucketOwner: data.repository.owner,
|
||||
bitbucketBuildPath: data.buildPath,
|
||||
bitbucketId: data.bitbucketId,
|
||||
@@ -188,7 +181,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
repo: "",
|
||||
slug: "",
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
@@ -225,7 +217,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
|
||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
@@ -279,7 +271,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("repository", {
|
||||
owner: repo.owner.username as string,
|
||||
repo: repo.name,
|
||||
slug: repo.slug,
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
|
||||
@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.gitlabPathNamespace && (
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
|
||||
@@ -34,7 +34,6 @@ export const DockerLogs = dynamic(
|
||||
export const badgeStateColor = (state: string) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
case "ready":
|
||||
return "green";
|
||||
case "exited":
|
||||
case "shutdown":
|
||||
@@ -143,7 +142,6 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -159,9 +157,6 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.currentState
|
||||
? ` ${container.currentState}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
@@ -171,13 +166,6 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{option === "swarm" &&
|
||||
services?.find((c) => c.containerId === containerId)?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
|
||||
<span className="font-medium">Error: </span>
|
||||
{services?.find((c) => c.containerId === containerId)?.error}
|
||||
</div>
|
||||
)}
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
Hammer,
|
||||
Loader2,
|
||||
PenSquare,
|
||||
RocketIcon,
|
||||
@@ -24,12 +22,6 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||
@@ -46,9 +38,6 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||
api.previewDeployment.delete.useMutation();
|
||||
|
||||
const { mutateAsync: redeployPreviewDeployment } =
|
||||
api.previewDeployment.redeploy.useMutation();
|
||||
|
||||
const {
|
||||
data: previewDeployments,
|
||||
refetch: refetchPreviewDeployments,
|
||||
@@ -57,8 +46,6 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
{ applicationId },
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
refetchInterval: (data) =>
|
||||
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -206,58 +193,6 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
</Button>
|
||||
</ShowDeploymentsModal>
|
||||
|
||||
<DialogAction
|
||||
title="Rebuild Preview Deployment"
|
||||
description="Are you sure you want to rebuild this preview deployment?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeployPreviewDeployment({
|
||||
previewDeploymentId:
|
||||
deployment.previewDeploymentId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Preview deployment rebuild started",
|
||||
);
|
||||
refetchPreviewDeployments();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error rebuilding preview deployment",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={status === "running"}
|
||||
className="gap-2"
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<Hammer className="size-4" />
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent
|
||||
sideOffset={5}
|
||||
className="z-[60]"
|
||||
>
|
||||
<p>
|
||||
Rebuild the preview deployment without
|
||||
downloading new code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
|
||||
<AddPreviewDomain
|
||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||
domainId={deployment.domain?.domainId}
|
||||
|
||||
@@ -54,7 +54,6 @@ const BitbucketProviderSchema = z.object({
|
||||
.object({
|
||||
repo: z.string().min(1, "Repo is required"),
|
||||
owner: z.string().min(1, "Owner is required"),
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
@@ -83,7 +82,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
repository: {
|
||||
owner: "",
|
||||
repo: "",
|
||||
slug: "",
|
||||
},
|
||||
bitbucketId: "",
|
||||
branch: "",
|
||||
@@ -116,14 +114,11 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
} = api.bitbucket.getBitbucketBranches.useQuery(
|
||||
{
|
||||
owner: repository?.owner,
|
||||
repo: repository?.slug || repository?.repo || "",
|
||||
repo: repository?.repo,
|
||||
bitbucketId,
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
!!repository?.owner &&
|
||||
!!(repository?.slug || repository?.repo) &&
|
||||
!!bitbucketId,
|
||||
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -134,7 +129,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
repository: {
|
||||
repo: data.bitbucketRepository || "",
|
||||
owner: data.bitbucketOwner || "",
|
||||
slug: data.bitbucketRepositorySlug || "",
|
||||
},
|
||||
composePath: data.composePath,
|
||||
bitbucketId: data.bitbucketId || "",
|
||||
@@ -148,7 +142,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
await mutateAsync({
|
||||
bitbucketBranch: data.branch,
|
||||
bitbucketRepository: data.repository.repo,
|
||||
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
|
||||
bitbucketOwner: data.repository.owner,
|
||||
bitbucketId: data.bitbucketId,
|
||||
composePath: data.composePath,
|
||||
@@ -190,7 +183,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
repo: "",
|
||||
slug: "",
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
@@ -227,7 +219,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
|
||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
@@ -281,7 +273,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("repository", {
|
||||
owner: repo.owner.username as string,
|
||||
repo: repo.name,
|
||||
slug: repo.slug,
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -97,16 +97,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
const repository = form.watch("repository");
|
||||
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 {
|
||||
data: repositories,
|
||||
isLoading: isLoadingRepositories,
|
||||
@@ -234,9 +224,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.gitlabPathNamespace && (
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
|
||||
@@ -128,7 +128,6 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -144,9 +143,6 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.currentState
|
||||
? ` ${container.currentState}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
@@ -156,13 +152,6 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{option === "swarm" &&
|
||||
services?.find((c) => c.containerId === containerId)?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
|
||||
<span className="font-medium">Error: </span>
|
||||
{services.find((c) => c.containerId === containerId)?.error}
|
||||
</div>
|
||||
)}
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -93,7 +93,6 @@ export const ShowDockerLogsCompose = ({
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
|
||||
|
||||
@@ -49,7 +47,6 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
|
||||
},
|
||||
);
|
||||
const [canEdit, setCanEdit] = useState(true);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.settings.updateTraefikFile.useMutation();
|
||||
@@ -69,15 +66,13 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
|
||||
if (!skipYamlValidation) {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
@@ -158,37 +153,14 @@ routers:
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="skip-yaml-validation"
|
||||
checked={skipYamlValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipYamlValidation(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-yaml-validation"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Skip YAML validation (for Go templating)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground -mt-2">
|
||||
Traefik supports Go templating in dynamic configs (e.g.{" "}
|
||||
<code className="text-xs">{"{{range}}"}</code>). Configs using
|
||||
templates will fail standard YAML validation. Check this to save
|
||||
without validation.
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Docker Image</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="postgres:18" {...field} />
|
||||
<Input placeholder="postgres:15" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -75,8 +75,8 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||
mongo: "mongo:7",
|
||||
mariadb: "mariadb:11",
|
||||
mysql: "mysql:8",
|
||||
postgres: "postgres:18",
|
||||
postgres: "postgres:15",
|
||||
redis: "redis:7",
|
||||
};
|
||||
|
||||
@@ -559,7 +559,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
type="password"
|
||||
placeholder="******************"
|
||||
autoComplete="one-time-code"
|
||||
enablePasswordGenerator={true}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -579,7 +578,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="******************"
|
||||
enablePasswordGenerator={true}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -288,12 +288,9 @@ export const ShowProjects = () => {
|
||||
)
|
||||
.some(Boolean);
|
||||
|
||||
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||
const accessibleEnvironment =
|
||||
project?.environments.find((env) => env.isDefault) ||
|
||||
project?.environments?.[0];
|
||||
|
||||
const hasNoEnvironments = !accessibleEnvironment;
|
||||
const productionEnvironment = project?.environments.find(
|
||||
(env) => env.isDefault,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -301,16 +298,7 @@ export const ShowProjects = () => {
|
||||
className="w-full lg:max-w-md"
|
||||
>
|
||||
<Link
|
||||
href={
|
||||
hasNoEnvironments
|
||||
? "#"
|
||||
: `/dashboard/project/${project.projectId}/environment/${accessibleEnvironment?.environmentId}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (hasNoEnvironments) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
href={`/dashboard/project/${project.projectId}/environment/${productionEnvironment?.environmentId}`}
|
||||
>
|
||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
||||
{haveServicesWithDomains ? (
|
||||
@@ -431,7 +419,7 @@ export const ShowProjects = () => {
|
||||
) : null}
|
||||
<CardHeader>
|
||||
<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">
|
||||
<BookIcon className="size-4 text-muted-foreground" />
|
||||
<span className="text-base font-medium leading-none">
|
||||
@@ -439,19 +427,9 @@ export const ShowProjects = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground break-all">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{project.description}
|
||||
</span>
|
||||
|
||||
{hasNoEnvironments && (
|
||||
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
|
||||
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
||||
<span className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
You have access to this project but no
|
||||
environments are available
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex self-start space-x-1">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export const SearchCommand = () => {
|
||||
<CommandGroup heading={"Projects"}>
|
||||
<CommandList>
|
||||
{data?.map((project) => {
|
||||
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||
// Find default environment, or fall back to first environment
|
||||
const defaultEnvironment =
|
||||
project.environments.find(
|
||||
(environment) => environment.isDefault,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { CreditCard, FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ShowInvoices } from "./show-invoices";
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
name: "Subscription",
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
export const ShowBillingInvoices = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = router.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6">
|
||||
<ShowInvoices />
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,13 +4,11 @@ import {
|
||||
AlertTriangle,
|
||||
CheckIcon,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Loader2,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -39,22 +37,7 @@ export const calculatePrice = (count: number, isAnnual = false) => {
|
||||
if (count <= 1) return 4.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 = () => {
|
||||
const router = useRouter();
|
||||
const { data: servers } = api.server.count.useQuery();
|
||||
const { data: admin } = api.user.get.useQuery();
|
||||
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
||||
@@ -93,41 +76,17 @@ export const ShowBilling = () => {
|
||||
|
||||
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>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<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>
|
||||
<CardDescription>Manage your subscription</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="flex flex-col gap-4 w-full mt-6">
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Tabs
|
||||
defaultValue="monthly"
|
||||
value={isAnnual ? "annual" : "monthly"}
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
|
||||
import type Stripe from "stripe";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formatDate = (timestamp: number | null) => {
|
||||
if (!timestamp) return "-";
|
||||
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
|
||||
const statusConfig: Record<
|
||||
Stripe.Invoice.Status,
|
||||
{ label: string; variant: "default" | "secondary" | "destructive" }
|
||||
> = {
|
||||
paid: { label: "Paid", variant: "default" },
|
||||
open: { label: "Open", variant: "secondary" },
|
||||
draft: { label: "Draft", variant: "secondary" },
|
||||
void: { label: "Void", variant: "destructive" },
|
||||
uncollectible: { label: "Uncollectible", variant: "destructive" },
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
return <Badge variant="secondary">Unknown</Badge>;
|
||||
}
|
||||
|
||||
const config = statusConfig[status] || {
|
||||
label: status,
|
||||
variant: "secondary" as const,
|
||||
};
|
||||
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
export const ShowInvoices = () => {
|
||||
const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[20vh]">
|
||||
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center">
|
||||
Loading invoices...
|
||||
<Loader2 className="animate-spin" />
|
||||
</span>
|
||||
</div>
|
||||
) : invoices && invoices.length > 0 ? (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Invoice</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Due Date</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoices.map((invoice) => (
|
||||
<TableRow key={invoice.id}>
|
||||
<TableCell className="font-medium">
|
||||
{invoice.number || invoice.id.slice(0, 12)}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(invoice.created)}</TableCell>
|
||||
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
|
||||
<TableCell>
|
||||
{formatAmount(invoice.amountDue, invoice.currency)}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{invoice.hostedInvoiceUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
invoice.hostedInvoiceUrl || "",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{invoice.invoicePdf && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(invoice.invoicePdf || "", "_blank")
|
||||
}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center min-h-[20vh] gap-2">
|
||||
<FileText className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No invoices found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your invoices will appear here once you have a subscription
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -40,10 +39,6 @@ const Schema = z.object({
|
||||
giteaUrl: z.string().min(1, {
|
||||
message: "Gitea URL is required",
|
||||
}),
|
||||
giteaInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
clientId: z.string().min(1, {
|
||||
message: "Client ID is required",
|
||||
}),
|
||||
@@ -75,7 +70,6 @@ export const AddGiteaProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -89,7 +83,6 @@ export const AddGiteaProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
});
|
||||
}, [form, webhookUrl, isOpen]);
|
||||
|
||||
@@ -102,7 +95,6 @@ export const AddGiteaProvider = () => {
|
||||
name: data.name,
|
||||
redirectUri: data.redirectUri,
|
||||
giteaUrl: data.giteaUrl,
|
||||
giteaInternalUrl: data.giteaInternalUrl || undefined,
|
||||
organizationName: data.organizationName,
|
||||
})) as unknown as GiteaProviderResponse;
|
||||
|
||||
@@ -231,29 +223,6 @@ export const AddGiteaProvider = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitea:3000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when Gitea runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach Gitea via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUri"
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -31,10 +30,6 @@ import { useUrl } from "@/utils/hooks/use-url";
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
giteaUrl: z.string().min(1, "Gitea URL is required"),
|
||||
giteaInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
clientId: z.string().min(1, "Client ID is required"),
|
||||
clientSecret: z.string().min(1, "Client Secret is required"),
|
||||
});
|
||||
@@ -99,7 +94,6 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
defaultValues: {
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
@@ -110,7 +104,6 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
form.reset({
|
||||
name: gitea.gitProvider?.name || "",
|
||||
giteaUrl: gitea.giteaUrl || "https://gitea.com",
|
||||
giteaInternalUrl: gitea.giteaInternalUrl || "",
|
||||
clientId: gitea.clientId || "",
|
||||
clientSecret: gitea.clientSecret || "",
|
||||
});
|
||||
@@ -123,7 +116,6 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
gitProviderId: gitea?.gitProvider?.gitProviderId || "",
|
||||
name: values.name,
|
||||
giteaUrl: values.giteaUrl,
|
||||
giteaInternalUrl: values.giteaInternalUrl ?? null,
|
||||
clientId: values.clientId,
|
||||
clientSecret: values.clientSecret,
|
||||
})
|
||||
@@ -232,28 +224,6 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitea:3000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when Gitea runs on the same instance as Dokploy. Used
|
||||
for OAuth token exchange to reach Gitea via internal network
|
||||
(e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -36,10 +35,6 @@ const Schema = z.object({
|
||||
gitlabUrl: z.string().min(1, {
|
||||
message: "GitLab URL is required",
|
||||
}),
|
||||
gitlabInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
applicationId: z.string().min(1, {
|
||||
message: "Application ID is required",
|
||||
}),
|
||||
@@ -71,7 +66,6 @@ export const AddGitlabProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -86,7 +80,6 @@ export const AddGitlabProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
});
|
||||
}, [form, isOpen]);
|
||||
|
||||
@@ -99,7 +92,6 @@ export const AddGitlabProvider = () => {
|
||||
name: data.name || "",
|
||||
redirectUri: data.redirectUri || "",
|
||||
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
|
||||
gitlabInternalUrl: data.gitlabInternalUrl || undefined,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
@@ -200,29 +192,6 @@ export const AddGitlabProvider = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gitlabInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitlab:80"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when GitLab runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach GitLab via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUri"
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -34,10 +33,6 @@ const Schema = z.object({
|
||||
gitlabUrl: z.string().url({
|
||||
message: "Invalid Gitlab URL",
|
||||
}),
|
||||
gitlabInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
groupName: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -66,7 +61,6 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: "",
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -78,7 +72,6 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: gitlab?.groupName || "",
|
||||
name: gitlab?.gitProvider.name || "",
|
||||
gitlabUrl: gitlab?.gitlabUrl || "",
|
||||
gitlabInternalUrl: gitlab?.gitlabInternalUrl || "",
|
||||
});
|
||||
}, [form, isOpen]);
|
||||
|
||||
@@ -89,7 +82,6 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: data.groupName || "",
|
||||
name: data.name || "",
|
||||
gitlabUrl: data.gitlabUrl || "",
|
||||
gitlabInternalUrl: data.gitlabInternalUrl ?? null,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
@@ -159,29 +151,6 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gitlabInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitlab:80"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when GitLab runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach GitLab via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="groupName"
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
GotifyIcon,
|
||||
LarkIcon,
|
||||
NtfyIcon,
|
||||
PushoverIcon,
|
||||
ResendIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
@@ -98,23 +96,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
.min(1, { message: "At least one email is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("resend"),
|
||||
apiKey: z.string().min(1, { message: "API Key is required" }),
|
||||
fromAddress: z
|
||||
.string()
|
||||
.min(1, { message: "From Address is required" })
|
||||
.email({ message: "Email is invalid" }),
|
||||
toAddresses: z
|
||||
.array(
|
||||
z.string().min(1, { message: "Email is required" }).email({
|
||||
message: "Email is invalid",
|
||||
}),
|
||||
)
|
||||
.min(1, { message: "At least one email is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("gotify"),
|
||||
@@ -133,16 +114,6 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
priority: z.number().min(1).max(5).default(3),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("pushover"),
|
||||
userKey: z.string().min(1, { message: "User Key is required" }),
|
||||
apiToken: z.string().min(1, { message: "API Token is required" }),
|
||||
priority: z.number().min(-2).max(2).default(0),
|
||||
retry: z.number().min(30).nullish(),
|
||||
expire: z.number().min(1).max(10800).nullish(),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("custom"),
|
||||
@@ -187,10 +158,6 @@ export const notificationsMap = {
|
||||
icon: <Mail size={29} className="text-muted-foreground" />,
|
||||
label: "Email",
|
||||
},
|
||||
resend: {
|
||||
icon: <ResendIcon className="text-muted-foreground" />,
|
||||
label: "Resend",
|
||||
},
|
||||
gotify: {
|
||||
icon: <GotifyIcon />,
|
||||
label: "Gotify",
|
||||
@@ -199,10 +166,6 @@ export const notificationsMap = {
|
||||
icon: <NtfyIcon />,
|
||||
label: "ntfy",
|
||||
},
|
||||
pushover: {
|
||||
icon: <PushoverIcon />,
|
||||
label: "Pushover",
|
||||
},
|
||||
custom: {
|
||||
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
|
||||
label: "Custom",
|
||||
@@ -236,8 +199,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
api.notification.testDiscordConnection.useMutation();
|
||||
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
|
||||
api.notification.testEmailConnection.useMutation();
|
||||
const { mutateAsync: testResendConnection, isLoading: isLoadingResend } =
|
||||
api.notification.testResendConnection.useMutation();
|
||||
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
|
||||
api.notification.testGotifyConnection.useMutation();
|
||||
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
|
||||
@@ -248,9 +209,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
||||
api.notification.testCustomConnection.useMutation();
|
||||
|
||||
const { mutateAsync: testPushoverConnection, isLoading: isLoadingPushover } =
|
||||
api.notification.testPushoverConnection.useMutation();
|
||||
|
||||
const customMutation = notificationId
|
||||
? api.notification.updateCustom.useMutation()
|
||||
: api.notification.createCustom.useMutation();
|
||||
@@ -266,9 +224,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
const emailMutation = notificationId
|
||||
? api.notification.updateEmail.useMutation()
|
||||
: api.notification.createEmail.useMutation();
|
||||
const resendMutation = notificationId
|
||||
? api.notification.updateResend.useMutation()
|
||||
: api.notification.createResend.useMutation();
|
||||
const gotifyMutation = notificationId
|
||||
? api.notification.updateGotify.useMutation()
|
||||
: api.notification.createGotify.useMutation();
|
||||
@@ -278,9 +233,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
const larkMutation = notificationId
|
||||
? api.notification.updateLark.useMutation()
|
||||
: api.notification.createLark.useMutation();
|
||||
const pushoverMutation = notificationId
|
||||
? api.notification.updatePushover.useMutation()
|
||||
: api.notification.createPushover.useMutation();
|
||||
|
||||
const form = useForm<NotificationSchema>({
|
||||
defaultValues: {
|
||||
@@ -308,7 +260,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if ((type === "email" || type === "resend") && fields.length === 0) {
|
||||
if (type === "email" && fields.length === 0) {
|
||||
append("");
|
||||
}
|
||||
}, [type, append, fields.length]);
|
||||
@@ -376,21 +328,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "resend") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
apiKey: notification.resend?.apiKey,
|
||||
toAddresses: notification.resend?.toAddresses,
|
||||
fromAddress: notification.resend?.fromAddress,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "gotify") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
@@ -456,23 +393,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "pushover") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
userKey: notification.pushover?.userKey,
|
||||
apiToken: notification.pushover?.apiToken,
|
||||
priority: notification.pushover?.priority,
|
||||
retry: notification.pushover?.retry ?? undefined,
|
||||
expire: notification.pushover?.expire ?? undefined,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
form.reset();
|
||||
@@ -484,12 +404,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
telegram: telegramMutation,
|
||||
discord: discordMutation,
|
||||
email: emailMutation,
|
||||
resend: resendMutation,
|
||||
gotify: gotifyMutation,
|
||||
ntfy: ntfyMutation,
|
||||
lark: larkMutation,
|
||||
custom: customMutation,
|
||||
pushover: pushoverMutation,
|
||||
};
|
||||
|
||||
const onSubmit = async (data: NotificationSchema) => {
|
||||
@@ -568,22 +486,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
emailId: notification?.emailId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "resend") {
|
||||
promise = resendMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
apiKey: data.apiKey,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
notificationId: notificationId || "",
|
||||
resendId: notification?.resendId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "gotify") {
|
||||
promise = gotifyMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
@@ -657,28 +559,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
notificationId: notificationId || "",
|
||||
customId: notification?.customId || "",
|
||||
});
|
||||
} else if (data.type === "pushover") {
|
||||
if (data.priority === 2 && (data.retry == null || data.expire == null)) {
|
||||
toast.error("Retry and expire are required for emergency priority (2)");
|
||||
return;
|
||||
}
|
||||
promise = pushoverMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
userKey: data.userKey,
|
||||
apiToken: data.apiToken,
|
||||
priority: data.priority,
|
||||
retry: data.priority === 2 ? data.retry : undefined,
|
||||
expire: data.priority === 2 ? data.expire : undefined,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
pushoverId: notification?.pushoverId || "",
|
||||
});
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
@@ -1101,96 +981,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "resend" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="re_********"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="from@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<FormLabel>To Addresses</FormLabel>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex flex-row gap-2 w-full"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`toAddresses.${index}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="email@example.com"
|
||||
className="w-full"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{type === "resend" &&
|
||||
"toAddresses" in form.formState.errors && (
|
||||
<div className="text-sm font-medium text-destructive">
|
||||
{form.formState?.errors?.toAddresses?.root?.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
append("");
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "gotify" && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -1465,147 +1255,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{type === "pushover" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="userKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>User Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="ub3de9kl2q..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="a3d9k2q7m4..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
defaultValue={0}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="0"
|
||||
value={field.value ?? 0}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "" || value === "-") {
|
||||
field.onChange(0);
|
||||
} else {
|
||||
const priority = Number.parseInt(value);
|
||||
if (
|
||||
!Number.isNaN(priority) &&
|
||||
priority >= -2 &&
|
||||
priority <= 2
|
||||
) {
|
||||
field.onChange(priority);
|
||||
}
|
||||
}
|
||||
}}
|
||||
type="number"
|
||||
min={-2}
|
||||
max={2}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Message priority (-2 to 2, default: 0, emergency: 2)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("priority") === 2 && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="retry"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Retry (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="30"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange(undefined);
|
||||
} else {
|
||||
const retry = Number.parseInt(value);
|
||||
if (!Number.isNaN(retry)) {
|
||||
field.onChange(retry);
|
||||
}
|
||||
}
|
||||
}}
|
||||
type="number"
|
||||
min={30}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
How often (in seconds) to retry. Minimum 30
|
||||
seconds.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="expire"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Expire (seconds)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3600"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange(undefined);
|
||||
} else {
|
||||
const expire = Number.parseInt(value);
|
||||
if (!Number.isNaN(expire)) {
|
||||
field.onChange(expire);
|
||||
}
|
||||
}
|
||||
}}
|
||||
type="number"
|
||||
min={1}
|
||||
max={10800}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
How long to keep retrying (max 10800 seconds / 3
|
||||
hours).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -1776,12 +1425,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingTelegram ||
|
||||
isLoadingDiscord ||
|
||||
isLoadingEmail ||
|
||||
isLoadingResend ||
|
||||
isLoadingGotify ||
|
||||
isLoadingNtfy ||
|
||||
isLoadingLark ||
|
||||
isLoadingCustom ||
|
||||
isLoadingPushover
|
||||
isLoadingCustom
|
||||
}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
@@ -1817,12 +1464,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
});
|
||||
} else if (data.type === "resend") {
|
||||
await testResendConnection({
|
||||
apiKey: data.apiKey,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
});
|
||||
} else if (data.type === "gotify") {
|
||||
await testGotifyConnection({
|
||||
serverUrl: data.serverUrl,
|
||||
@@ -1856,22 +1497,6 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
endpoint: data.endpoint,
|
||||
headers: headersRecord,
|
||||
});
|
||||
} else if (data.type === "pushover") {
|
||||
if (
|
||||
data.priority === 2 &&
|
||||
(data.retry == null || data.expire == null)
|
||||
) {
|
||||
throw new Error(
|
||||
"Retry and expire are required for emergency priority (2)",
|
||||
);
|
||||
}
|
||||
await testPushoverConnection({
|
||||
userKey: data.userKey,
|
||||
apiToken: data.apiToken,
|
||||
priority: data.priority,
|
||||
retry: data.priority === 2 ? data.retry : undefined,
|
||||
expire: data.priority === 2 ? data.expire : undefined,
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
GotifyIcon,
|
||||
LarkIcon,
|
||||
NtfyIcon,
|
||||
ResendIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
@@ -37,7 +36,7 @@ export const ShowNotifications = () => {
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add your providers to receive notifications, like Discord, Slack,
|
||||
Telegram, Email, Resend, Lark.
|
||||
Telegram, Email, Lark.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -87,11 +86,6 @@ export const ShowNotifications = () => {
|
||||
<Mail className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "resend" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<ResendIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "gotify" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<GotifyIcon className="size-6" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2, Palette, User } from "lucide-react";
|
||||
import { Loader2, User } from "lucide-react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils";
|
||||
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { Configure2FA } from "./configure-2fa";
|
||||
@@ -41,7 +40,7 @@ const profileSchema = z.object({
|
||||
password: z.string().nullable(),
|
||||
currentPassword: z.string().nullable(),
|
||||
image: z.string().optional(),
|
||||
firstName: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
allowImpersonation: z.boolean().optional().default(false),
|
||||
});
|
||||
@@ -75,7 +74,6 @@ export const ProfileForm = () => {
|
||||
} = api.user.update.useMutation();
|
||||
const { t } = useTranslation("settings");
|
||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const availableAvatars = useMemo(() => {
|
||||
if (gravatarHash === null) return randomImages;
|
||||
@@ -91,7 +89,7 @@ export const ProfileForm = () => {
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: "",
|
||||
allowImpersonation: data?.user?.allowImpersonation || false,
|
||||
firstName: data?.user?.firstName || "",
|
||||
name: data?.user?.firstName || "",
|
||||
lastName: data?.user?.lastName || "",
|
||||
},
|
||||
resolver: zodResolver(profileSchema),
|
||||
@@ -106,7 +104,7 @@ export const ProfileForm = () => {
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: form.getValues("currentPassword") || "",
|
||||
allowImpersonation: data?.user?.allowImpersonation,
|
||||
firstName: data?.user?.firstName || "",
|
||||
name: data?.user?.firstName || "",
|
||||
lastName: data?.user?.lastName || "",
|
||||
},
|
||||
{
|
||||
@@ -131,7 +129,7 @@ export const ProfileForm = () => {
|
||||
image: values.image,
|
||||
currentPassword: values.currentPassword || undefined,
|
||||
allowImpersonation: values.allowImpersonation,
|
||||
firstName: values.firstName || undefined,
|
||||
name: values.name || undefined,
|
||||
lastName: values.lastName || undefined,
|
||||
});
|
||||
await refetch();
|
||||
@@ -141,7 +139,7 @@ export const ProfileForm = () => {
|
||||
password: "",
|
||||
image: values.image,
|
||||
currentPassword: "",
|
||||
firstName: values.firstName || "",
|
||||
name: values.name || "",
|
||||
lastName: values.lastName || "",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -184,7 +182,7 @@ export const ProfileForm = () => {
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
@@ -276,8 +274,16 @@ export const ProfileForm = () => {
|
||||
onValueChange={(e) => {
|
||||
field.onChange(e);
|
||||
}}
|
||||
defaultValue={getAvatarType(field.value)}
|
||||
value={getAvatarType(field.value)}
|
||||
defaultValue={
|
||||
field.value?.startsWith("data:")
|
||||
? "upload"
|
||||
: field.value
|
||||
}
|
||||
value={
|
||||
field.value?.startsWith("data:")
|
||||
? "upload"
|
||||
: field.value
|
||||
}
|
||||
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
||||
>
|
||||
<FormItem key="no-avatar">
|
||||
@@ -364,40 +370,6 @@ export const ProfileForm = () => {
|
||||
/>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem key="color-avatar">
|
||||
<FormLabel className="[&:has([data-state=checked])>.color-avatar]:border-primary [&:has([data-state=checked])>.color-avatar]:border-1 [&:has([data-state=checked])>.color-avatar]:p-px cursor-pointer relative">
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="color"
|
||||
className="sr-only"
|
||||
/>
|
||||
</FormControl>
|
||||
<div
|
||||
className="color-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-colors flex items-center justify-center overflow-hidden cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: isSolidColorAvatar(
|
||||
field.value,
|
||||
)
|
||||
? field.value
|
||||
: undefined,
|
||||
}}
|
||||
onClick={() =>
|
||||
colorInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
{!isSolidColorAvatar(field.value) && (
|
||||
<Palette className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
className="absolute opacity-0 pointer-events-none w-12 h-12 top-0 left-0"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
{availableAvatars.map((image) => (
|
||||
<FormItem key={image}>
|
||||
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
|
||||
|
||||
@@ -23,8 +23,6 @@ export const ShowDokployActions = () => {
|
||||
|
||||
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
|
||||
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
|
||||
const { mutateAsync: cleanAllDeploymentQueue } =
|
||||
api.settings.cleanAllDeploymentQueue.useMutation();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -89,21 +87,6 @@ export const ShowDokployActions = () => {
|
||||
Clean Redis
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
await cleanAllDeploymentQueue()
|
||||
.then(() => {
|
||||
toast.success("Deployment queue cleaned");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error cleaning deployment queue");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clean all deployment queue
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import { api } from "@/utils/api";
|
||||
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
|
||||
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
|
||||
@@ -34,45 +33,14 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
serverId,
|
||||
});
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
pollInterval: 4000,
|
||||
successMessage: "Traefik dashboard updated successfully",
|
||||
onSuccess: () => {
|
||||
refetchDashboard();
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
execute: executeReloadWithHealthCheck,
|
||||
isExecuting: isReloadHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
pollInterval: 4000,
|
||||
successMessage: "Traefik Reloaded",
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={
|
||||
reloadTraefikIsLoading ||
|
||||
toggleDashboardIsLoading ||
|
||||
isHealthCheckExecuting ||
|
||||
isReloadHealthCheckExecuting
|
||||
}
|
||||
disabled={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
>
|
||||
<Button
|
||||
isLoading={
|
||||
reloadTraefikIsLoading ||
|
||||
toggleDashboardIsLoading ||
|
||||
isHealthCheckExecuting ||
|
||||
isReloadHealthCheckExecuting
|
||||
}
|
||||
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
variant="outline"
|
||||
>
|
||||
{t("settings.server.webServer.traefik.label")}
|
||||
@@ -86,19 +54,15 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await executeReloadWithHealthCheck(() =>
|
||||
reloadTraefik({ serverId }),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
(error as Error)?.message ||
|
||||
"Failed to reload Traefik. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
await reloadTraefik({
|
||||
serverId: serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Traefik Reloaded");
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
disabled={isReloadHealthCheckExecuting}
|
||||
>
|
||||
<span>{t("settings.server.webServer.reload")}</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -144,21 +108,24 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
</div>
|
||||
}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await executeWithHealthCheck(() =>
|
||||
toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
serverId: serverId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
(error as Error)?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
await toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
serverId: serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(
|
||||
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
|
||||
);
|
||||
refetchDashboard();
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
}}
|
||||
disabled={toggleDashboardIsLoading || isHealthCheckExecuting}
|
||||
disabled={toggleDashboardIsLoading}
|
||||
type="default"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -7,12 +7,9 @@ interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: !serverId,
|
||||
},
|
||||
);
|
||||
const { data, refetch } = api.user.get.useQuery(undefined, {
|
||||
enabled: !serverId,
|
||||
});
|
||||
|
||||
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
|
||||
{
|
||||
@@ -25,7 +22,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
|
||||
const enabled = serverId
|
||||
? server?.enableDockerCleanup
|
||||
: data?.enableDockerCleanup;
|
||||
: data?.user.enableDockerCleanup;
|
||||
|
||||
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
|
||||
|
||||
@@ -33,10 +30,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
enableDockerCleanup: checked,
|
||||
...(serverId && { serverId }),
|
||||
} as {
|
||||
enableDockerCleanup: boolean;
|
||||
serverId?: string;
|
||||
serverId: serverId,
|
||||
});
|
||||
if (serverId) {
|
||||
await refetchServer();
|
||||
|
||||
@@ -80,7 +80,7 @@ const Schema = z.object({
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
export const SetupMonitoring = ({ serverId }: Props) => {
|
||||
const { data: serverData } = serverId
|
||||
const { data } = serverId
|
||||
? api.server.one.useQuery(
|
||||
{
|
||||
serverId: serverId || "",
|
||||
@@ -89,14 +89,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
|
||||
enabled: !!serverId,
|
||||
},
|
||||
)
|
||||
: { data: null };
|
||||
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery(undefined, {
|
||||
enabled: !serverId,
|
||||
});
|
||||
|
||||
const data = serverId ? serverData : webServerSettings;
|
||||
: api.user.getServerMetrics.useQuery();
|
||||
|
||||
const url = useUrl();
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -88,15 +89,15 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<Button
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
Setup Server <Settings className="size-4" />
|
||||
</Button>
|
||||
Setup Server
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-4xl ">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
Pencil,
|
||||
ServerIcon,
|
||||
Settings,
|
||||
Terminal,
|
||||
Trash2,
|
||||
User,
|
||||
@@ -29,7 +31,9 @@ import {
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -281,32 +285,7 @@ export const ShowServers = () => {
|
||||
|
||||
{/* Compact Actions */}
|
||||
{isActive && (
|
||||
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<SetupServer
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="max-w-xs"
|
||||
side="bottom"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">
|
||||
Setup Server
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure and initialize your
|
||||
server with Docker, Traefik, and
|
||||
other essential services
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t mt-auto">
|
||||
<TooltipProvider>
|
||||
{server.sshKeyId && (
|
||||
<Tooltip>
|
||||
@@ -332,6 +311,20 @@ export const ShowServers = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<SetupServer
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Setup Server</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
|
||||
@@ -67,7 +67,7 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
|
||||
|
||||
export const WebDomain = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||
const { data, refetch } = api.user.get.useQuery();
|
||||
const { mutateAsync, isLoading } =
|
||||
api.settings.assignDomainServer.useMutation();
|
||||
|
||||
@@ -82,15 +82,15 @@ export const WebDomain = () => {
|
||||
});
|
||||
const https = form.watch("https");
|
||||
const domain = form.watch("domain") || "";
|
||||
const host = data?.host || "";
|
||||
const host = data?.user?.host || "";
|
||||
const hasChanged = domain !== host;
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
domain: data?.host || "",
|
||||
certificateType: data?.certificateType || "none",
|
||||
letsEncryptEmail: data?.letsEncryptEmail || "",
|
||||
https: data?.https || false,
|
||||
domain: data?.user?.host || "",
|
||||
certificateType: data?.user?.certificateType,
|
||||
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
|
||||
https: data?.user?.https || false,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
@@ -16,8 +16,7 @@ import { UpdateServer } from "./web-server/update-server";
|
||||
|
||||
export const WebServer = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
const { data } = api.user.get.useQuery();
|
||||
|
||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||
|
||||
@@ -54,7 +53,7 @@ export const WebServer = () => {
|
||||
|
||||
<div className="flex items-center flex-wrap justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Server IP: {webServerSettings?.serverIp}
|
||||
Server IP: {data?.user.serverIp}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Version: {dokployVersion}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -47,14 +46,6 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.settings.writeTraefikEnv.useMutation();
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
successMessage: "Traefik Env Updated",
|
||||
});
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
env: data || "",
|
||||
@@ -72,16 +63,16 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
try {
|
||||
await executeWithHealthCheck(() =>
|
||||
mutateAsync({
|
||||
env: data.env,
|
||||
serverId,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
toast.error("Error updating the Traefik env");
|
||||
}
|
||||
await mutateAsync({
|
||||
env: data.env,
|
||||
serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Traefik Env Updated");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the Traefik env");
|
||||
});
|
||||
};
|
||||
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
@@ -163,8 +154,8 @@ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_PROVIDER=cloudflare
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading || isHealthCheckExecuting}
|
||||
disabled={canEdit || isLoading || isHealthCheckExecuting}
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
form="hook-form-update-server-traefik-config"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -77,19 +76,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
});
|
||||
|
||||
const { mutateAsync: updatePorts, isLoading } =
|
||||
api.settings.updateTraefikPorts.useMutation();
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
|
||||
onSuccess: () => {
|
||||
refetchPorts();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
api.settings.updateTraefikPorts.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchPorts();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPorts) {
|
||||
@@ -108,12 +99,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
|
||||
const onSubmit = async (data: TraefikPortsForm) => {
|
||||
try {
|
||||
await executeWithHealthCheck(() =>
|
||||
updatePorts({
|
||||
serverId,
|
||||
additionalPorts: data.ports,
|
||||
}),
|
||||
);
|
||||
await updatePorts({
|
||||
serverId,
|
||||
additionalPorts: data.ports,
|
||||
});
|
||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "Error updating Traefik ports");
|
||||
@@ -327,7 +317,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="text-sm"
|
||||
isLoading={isLoading || isHealthCheckExecuting}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -46,15 +46,15 @@ interface Props {
|
||||
export const UpdateServerIp = ({ children }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||
const { data } = api.user.get.useQuery();
|
||||
const { data: ip } = api.server.publicIp.useQuery();
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.settings.updateServerIp.useMutation();
|
||||
api.user.update.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
serverIp: data?.serverIp || "",
|
||||
serverIp: data?.user.serverIp || "",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
@@ -62,11 +62,13 @@ export const UpdateServerIp = ({ children }: Props) => {
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
serverIp: data.serverIp || "",
|
||||
serverIp: data.user.serverIp || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const setCurrentIp = () => {
|
||||
if (!ip) return;
|
||||
form.setValue("serverIp", ip);
|
||||
@@ -78,7 +80,7 @@ export const UpdateServerIp = ({ children }: Props) => {
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Server IP Updated");
|
||||
await refetch();
|
||||
await utils.user.get.invalidate();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -231,49 +231,3 @@ export const NtfyIcon = ({ className }: Props) => {
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const PushoverIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 600 600"
|
||||
className={cn("size-8", className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g stroke="none" strokeWidth="1">
|
||||
<ellipse
|
||||
style={{ fillRule: "evenodd" }}
|
||||
fill="#249DF1"
|
||||
transform="matrix(-0.674571, 0.73821, -0.73821, -0.674571, 556.833239, 241.613465)"
|
||||
cx="216.308"
|
||||
cy="152.076"
|
||||
rx="296.855"
|
||||
ry="296.855"
|
||||
/>
|
||||
<path
|
||||
fill="#FFFFFF"
|
||||
d="M 280.949 172.514 L 355.429 162.714 L 282.909 326.374 L 282.909 326.374 C 295.649 325.394 308.142 321.067 320.389 313.394 L 320.389 313.394 L 320.389 313.394 C 332.642 305.714 343.916 296.077 354.209 284.484 L 354.209 284.484 L 354.209 284.484 C 364.496 272.884 373.396 259.981 380.909 245.774 L 380.909 245.774 L 380.909 245.774 C 388.422 231.561 393.812 217.594 397.079 203.874 L 397.079 203.874 L 397.079 203.874 C 399.039 195.381 399.939 187.214 399.779 179.374 L 399.779 179.374 L 399.779 179.374 C 399.612 171.534 397.569 164.674 393.649 158.794 L 393.649 158.794 L 393.649 158.794 C 389.729 152.914 383.766 148.177 375.759 144.584 L 375.759 144.584 L 375.759 144.584 C 367.759 140.991 356.899 139.194 343.179 139.194 L 343.179 139.194 L 343.179 139.194 C 327.172 139.194 311.409 141.807 295.889 147.034 L 295.889 147.034 L 295.889 147.034 C 280.376 152.261 266.002 159.857 252.769 169.824 L 252.769 169.824 L 252.769 169.824 C 239.542 179.784 228.029 192.197 218.229 207.064 L 218.229 207.064 L 218.229 207.064 C 208.429 221.924 201.406 238.827 197.159 257.774 L 197.159 257.774 L 197.159 257.774 C 195.526 263.981 194.546 268.961 194.219 272.714 L 194.219 272.714 L 194.219 272.714 C 193.892 276.474 193.812 279.577 193.979 282.024 L 193.979 282.024 L 193.979 282.024 C 194.139 284.477 194.462 286.357 194.949 287.664 L 194.949 287.664 L 194.949 287.664 C 195.442 288.971 195.852 290.277 196.179 291.584 L 196.179 291.584 L 196.179 291.584 C 179.519 291.584 167.349 288.234 159.669 281.534 L 159.669 281.534 L 159.669 281.534 C 151.996 274.841 150.119 263.164 154.039 246.504 L 154.039 246.504 L 154.039 246.504 C 157.959 229.191 166.862 212.694 180.749 197.014 L 180.749 197.014 L 180.749 197.014 C 194.629 181.334 211.122 167.531 230.229 155.604 L 230.229 155.604 L 230.229 155.604 C 249.342 143.684 270.249 134.214 292.949 127.194 L 292.949 127.194 L 292.949 127.194 C 315.656 120.167 337.789 116.654 359.349 116.654 L 359.349 116.654 L 359.349 116.654 C 378.296 116.654 394.219 119.347 407.119 124.734 L 407.119 124.734 L 407.119 124.734 C 420.026 130.127 430.072 137.234 437.259 146.054 L 437.259 146.054 L 437.259 146.054 C 444.446 154.874 448.936 165.164 450.729 176.924 L 450.729 176.924 L 450.729 176.924 C 452.529 188.684 451.959 200.934 449.019 213.674 L 449.019 213.674 L 449.019 213.674 C 445.426 229.027 438.646 244.464 428.679 259.984 L 428.679 259.984 L 428.679 259.984 C 418.719 275.497 406.226 289.544 391.199 302.124 L 391.199 302.124 L 391.199 302.124 C 376.172 314.697 358.939 324.904 339.499 332.744 L 339.499 332.744 L 339.499 332.744 C 320.066 340.584 299.406 344.504 277.519 344.504 L 277.519 344.504 L 275.069 344.504 L 212.839 484.154 L 142.279 484.154 L 280.949 172.514 Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResendIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn("size-8", className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.12" />
|
||||
<path
|
||||
d="M8 17V7h6a3 3 0 0 1 0 6H8m6 0 2 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,10 +18,9 @@ import {
|
||||
Forward,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
Key,
|
||||
HeartIcon,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
LogIn,
|
||||
type LucideIcon,
|
||||
Package,
|
||||
PieChart,
|
||||
@@ -398,24 +397,6 @@ const MENU: Menu = {
|
||||
// Only enabled for admins in cloud environments
|
||||
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "License",
|
||||
url: "/dashboard/settings/license",
|
||||
icon: Key,
|
||||
// Only enabled for admins in non-cloud environments
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "SSO",
|
||||
url: "/dashboard/settings/sso",
|
||||
icon: LogIn,
|
||||
// Enabled for admins in both cloud and self-hosted (enterprise)
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
],
|
||||
|
||||
help: [
|
||||
@@ -429,6 +410,18 @@ const MENU: Menu = {
|
||||
url: "https://discord.gg/2tBnJ3jDJc",
|
||||
icon: CircleHelp,
|
||||
},
|
||||
{
|
||||
name: "Sponsor",
|
||||
url: "https://opencollective.com/dokploy",
|
||||
icon: ({ className }) => (
|
||||
<HeartIcon
|
||||
className={cn(
|
||||
"text-red-500 fill-red-600 animate-heartbeat",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SignInWithGithub() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "github",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("An error occurred while signing in with GitHub", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="w-full mb-4"
|
||||
onClick={handleClick}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<svg viewBox="0 0 438.549 438.549" className="mr-2 size-4">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with GitHub
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SignInWithGoogle() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("An error occurred while signing in with Google", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="w-full mb-4"
|
||||
onClick={handleClick}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="mr-2 size-4">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Lock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface EnterpriseFeatureLockedProps {
|
||||
/** Optional title override */
|
||||
title?: string;
|
||||
/** Optional description override */
|
||||
description?: string;
|
||||
/** Optional custom CTA label */
|
||||
ctaLabel?: string;
|
||||
/** Optional CTA href (default: /dashboard/settings/license) */
|
||||
ctaHref?: string;
|
||||
/** Compact variant (less padding, smaller icon) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a locked state for enterprise features when the user has no valid license.
|
||||
* Use standalone or via EnterpriseFeatureGate.
|
||||
*/
|
||||
export function EnterpriseFeatureLocked({
|
||||
title = "Enterprise feature",
|
||||
description = "This feature is part of Dokploy Enterprise. Add a valid license to use it.",
|
||||
ctaLabel = "Go to License",
|
||||
ctaHref = "/dashboard/settings/license",
|
||||
compact = false,
|
||||
}: EnterpriseFeatureLockedProps) {
|
||||
return (
|
||||
<Card className="border-dashed bg-transparent">
|
||||
<CardHeader className={compact ? "pb-2" : undefined}>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? "rounded-full bg-muted p-3"
|
||||
: "rounded-full bg-muted p-4"
|
||||
}
|
||||
>
|
||||
<Lock
|
||||
className={
|
||||
compact
|
||||
? "size-6 text-muted-foreground"
|
||||
: "size-8 text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg">{title}</CardTitle>
|
||||
<CardDescription className="max-w-sm mx-auto">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={compact ? "pt-0" : undefined}>
|
||||
<div className="flex justify-center">
|
||||
<Button asChild variant="secondary" size={compact ? "sm" : "default"}>
|
||||
<Link href={ctaHref}>{ctaLabel}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface EnterpriseFeatureGateProps {
|
||||
children: React.ReactNode;
|
||||
/** Props for the locked state when license is invalid */
|
||||
lockedProps?: Omit<EnterpriseFeatureLockedProps, "compact">;
|
||||
/** Show loading spinner while checking license */
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders children only when the instance has a valid enterprise license.
|
||||
* Otherwise shows EnterpriseFeatureLocked.
|
||||
*/
|
||||
export function EnterpriseFeatureGate({
|
||||
children,
|
||||
lockedProps,
|
||||
fallback,
|
||||
}: EnterpriseFeatureGateProps) {
|
||||
const { data: haveValidLicense, isLoading } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
if (fallback) return <>{fallback}</>;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Checking license...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!haveValidLicense) {
|
||||
return <EnterpriseFeatureLocked {...lockedProps} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { Key, Loader2, ShieldCheck } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export function LicenseKeySettings() {
|
||||
const utils = api.useUtils();
|
||||
const { data, isLoading } = api.licenseKey.getEnterpriseSettings.useQuery();
|
||||
const { mutateAsync: updateEnterpriseSettings, isLoading: isSaving } =
|
||||
api.licenseKey.updateEnterpriseSettings.useMutation();
|
||||
const { mutateAsync: activateLicenseKey, isLoading: isActivating } =
|
||||
api.licenseKey.activate.useMutation();
|
||||
const { mutateAsync: validateLicenseKey, isLoading: isValidating } =
|
||||
api.licenseKey.validate.useMutation();
|
||||
const { mutateAsync: deactivateLicenseKey, isLoading: isDeactivating } =
|
||||
api.licenseKey.deactivate.useMutation();
|
||||
const { data: haveValidLicenseKey, isLoading: isCheckingLicenseKey } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
const [licenseKey, setLicenseKey] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.licenseKey) {
|
||||
setLicenseKey(data.licenseKey);
|
||||
}
|
||||
}, [data?.licenseKey]);
|
||||
|
||||
const enabled = !!data?.enableEnterpriseFeatures;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||
{isCheckingLicenseKey ? (
|
||||
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Checking license key...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="size-6 text-muted-foreground" />
|
||||
<CardTitle className="text-xl">License Key</CardTitle>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={isLoading || isSaving || isDeactivating}
|
||||
onCheckedChange={async (next) => {
|
||||
try {
|
||||
await updateEnterpriseSettings({
|
||||
enableEnterpriseFeatures: next,
|
||||
});
|
||||
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||
toast.success("Enterprise features updated");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to update enterprise features");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
To unlock extra features you need an enterprise license key.
|
||||
Contact us{" "}
|
||||
<Link
|
||||
href="https://dokploy.com/contact"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
{enabled ? (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="licenseKey">
|
||||
License Key
|
||||
</label>
|
||||
<Input
|
||||
id="licenseKey"
|
||||
placeholder="Enter your enterprise license key"
|
||||
value={licenseKey}
|
||||
onChange={(e) => setLicenseKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:justify-self-end flex gap-2">
|
||||
{haveValidLicenseKey && (
|
||||
<DialogAction
|
||||
title="Deactivate License Key"
|
||||
description="Are you sure you want to deactivate this license key? This will disable enterprise features."
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deactivateLicenseKey();
|
||||
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||
await utils.licenseKey.haveValidLicenseKey.invalidate();
|
||||
setLicenseKey("");
|
||||
toast.success("License key deactivated");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to deactivate license key",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={isDeactivating || !haveValidLicenseKey}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={isDeactivating || !haveValidLicenseKey}
|
||||
isLoading={isDeactivating}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{haveValidLicenseKey && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
isSaving || isCheckingLicenseKey || isDeactivating
|
||||
}
|
||||
isLoading={isValidating}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const valid = await validateLicenseKey();
|
||||
if (valid) {
|
||||
toast.success("License key is valid");
|
||||
} else {
|
||||
toast.error("License key is invalid");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to validate license key",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Validate
|
||||
</Button>
|
||||
)}
|
||||
{!haveValidLicenseKey && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isSaving || isValidating || isDeactivating}
|
||||
isLoading={isActivating}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await activateLicenseKey({ licenseKey });
|
||||
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||
await utils.licenseKey.haveValidLicenseKey.invalidate();
|
||||
toast.success("License key activated");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to activate license key",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 justify-center min-h-[30vh] text-center">
|
||||
<div className="flex flex-col items-center gap-2 max-w-[400px]">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<ShieldCheck className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">Enterprise Features</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unlock advanced capabilities like SSO, Audit logs,
|
||||
whitelabeling and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateEnterpriseSettings({
|
||||
enableEnterpriseFeatures: true,
|
||||
});
|
||||
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||
toast.success("Enterprise features enabled");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to enable enterprise features");
|
||||
}
|
||||
}}
|
||||
isLoading={isSaving}
|
||||
disabled={isLoading || isDeactivating}
|
||||
>
|
||||
Enable Enterprise Features
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { FieldArrayPath } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DEFAULT_SCOPES = ["openid", "email", "profile"];
|
||||
|
||||
const domainsArraySchema = z
|
||||
.array(z.string().trim())
|
||||
.superRefine((arr, ctx) => {
|
||||
const filled = arr.filter((s) => s.length > 0);
|
||||
if (filled.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one domain is required",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const scopesArraySchema = z.array(z.string().trim());
|
||||
|
||||
const oidcProviderSchema = z.object({
|
||||
providerId: z.string().min(1, "Provider ID is required").trim(),
|
||||
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
|
||||
domains: domainsArraySchema,
|
||||
clientId: z.string().min(1, "Client ID is required").trim(),
|
||||
clientSecret: z.string().min(1, "Client secret is required"),
|
||||
scopes: scopesArraySchema,
|
||||
});
|
||||
|
||||
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
|
||||
|
||||
interface RegisterOidcDialogProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const formDefaultValues = {
|
||||
providerId: "",
|
||||
issuer: "",
|
||||
domains: [""],
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
scopes: [...DEFAULT_SCOPES],
|
||||
};
|
||||
|
||||
export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutateAsync, isLoading } = api.sso.register.useMutation();
|
||||
|
||||
const form = useForm<OidcProviderForm>({
|
||||
resolver: zodResolver(oidcProviderSchema),
|
||||
defaultValues: formDefaultValues,
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "domains" as FieldArrayPath<OidcProviderForm>,
|
||||
});
|
||||
|
||||
const {
|
||||
fields: scopeFields,
|
||||
append: appendScope,
|
||||
remove: removeScope,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "scopes" as FieldArrayPath<OidcProviderForm>,
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onSubmit = async (data: OidcProviderForm) => {
|
||||
try {
|
||||
const scopes = data.scopes.filter(Boolean).length
|
||||
? data.scopes.filter(Boolean)
|
||||
: DEFAULT_SCOPES;
|
||||
|
||||
const isAzure = data.issuer.includes("login.microsoftonline.com");
|
||||
const mapping = isAzure
|
||||
? {
|
||||
id: "sub",
|
||||
email: "preferred_username",
|
||||
emailVerified: "email_verified",
|
||||
name: "name",
|
||||
}
|
||||
: {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
emailVerified: "email_verified",
|
||||
name: "preferred_username",
|
||||
image: "picture",
|
||||
};
|
||||
await mutateAsync({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domains: data.domains,
|
||||
oidcConfig: {
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
scopes,
|
||||
pkce: true,
|
||||
mapping,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("OIDC provider registered successfully");
|
||||
form.reset(formDefaultValues);
|
||||
setOpen(false);
|
||||
await utils.sso.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to register SSO provider",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Register OIDC provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
|
||||
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
|
||||
from the issuer URL when possible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. okta or my-idp" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Unique identifier; used in callback URL path.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issuer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issuer URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://idp.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Discovery document is fetched from{" "}
|
||||
<code className="rounded bg-muted px-1">
|
||||
{"{issuer}"}/.well-known/openid-configuration
|
||||
</code>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Domains</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => (append as (value: string) => void)("")}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Add domain
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Email domains that use this provider (sign-in by email and org
|
||||
assignment; subdomains matched automatically).
|
||||
</p>
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`domains.${index}`}
|
||||
render={({ field: inputField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="company.com"
|
||||
className="flex-1"
|
||||
{...inputField}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
disabled={fields.length <= 1}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const err = form.formState.errors.domains;
|
||||
const msg =
|
||||
typeof err?.message === "string"
|
||||
? err.message
|
||||
: (err as { root?: { message?: string } } | undefined)?.root
|
||||
?.message;
|
||||
return msg ? (
|
||||
<p className="text-sm font-medium text-destructive">{msg}</p>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Client ID from IdP" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Client secret from IdP"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Scopes (optional)</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => (appendScope as (value: string) => void)("")}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Add scope
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
OIDC scopes to request (e.g. openid, email, profile). If empty,
|
||||
openid, email and profile are used.
|
||||
</FormDescription>
|
||||
{scopeFields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`scopes.${index}`}
|
||||
render={({ field: inputField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="openid"
|
||||
className="flex-1"
|
||||
{...inputField}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeScope(index)}
|
||||
disabled={scopeFields.length <= 1}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Register provider
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const domainsArraySchema = z
|
||||
.array(z.string().trim())
|
||||
.superRefine((arr, ctx) => {
|
||||
const filled = arr.filter((s) => s.length > 0);
|
||||
if (filled.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one domain is required",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const samlProviderSchema = z.object({
|
||||
providerId: z.string().min(1, "Provider ID is required").trim(),
|
||||
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
|
||||
domains: domainsArraySchema,
|
||||
entryPoint: z
|
||||
.string()
|
||||
.min(1, "IdP SSO URL is required")
|
||||
.url("Invalid URL")
|
||||
.trim(),
|
||||
cert: z.string().min(1, "IdP signing certificate is required"),
|
||||
idpMetadataXml: z.string().optional(),
|
||||
});
|
||||
|
||||
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
|
||||
|
||||
interface RegisterSamlDialogProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const formDefaultValues: SamlProviderForm = {
|
||||
providerId: "",
|
||||
issuer: "",
|
||||
domains: [""],
|
||||
entryPoint: "",
|
||||
cert: "",
|
||||
idpMetadataXml: "",
|
||||
};
|
||||
|
||||
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutateAsync, isLoading } = api.sso.register.useMutation();
|
||||
|
||||
const [baseURL, setBaseURL] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setBaseURL(window.location.origin);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const form = useForm<SamlProviderForm>({
|
||||
resolver: zodResolver(samlProviderSchema),
|
||||
defaultValues: formDefaultValues,
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "domains" as FieldArrayPath<SamlProviderForm>,
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onSubmit = async (data: SamlProviderForm) => {
|
||||
try {
|
||||
// maybe add the /saml/metadata endpoint to the baseURL
|
||||
const baseURLWithMetadata = `${baseURL}/saml/metadata`;
|
||||
const generateSpMetadata = (providerId: string) => {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${baseURL}">
|
||||
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${baseURL}/api/auth/sso/saml2/callback/${providerId}" index="1"/>
|
||||
</md:SPSSODescriptor>
|
||||
</md:EntityDescriptor>`;
|
||||
};
|
||||
|
||||
await mutateAsync({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domains: data.domains,
|
||||
samlConfig: {
|
||||
entryPoint: data.entryPoint,
|
||||
cert: data.cert,
|
||||
callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`,
|
||||
audience: baseURL,
|
||||
idpMetadata: data.idpMetadataXml?.trim()
|
||||
? { metadata: data.idpMetadataXml.trim() }
|
||||
: undefined,
|
||||
spMetadata: {
|
||||
metadata: generateSpMetadata(data.providerId),
|
||||
},
|
||||
mapping: {
|
||||
id: "nameID",
|
||||
email: "email",
|
||||
name: "displayName",
|
||||
firstName: "givenName",
|
||||
lastName: "surname",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("SAML provider registered successfully");
|
||||
form.reset(formDefaultValues);
|
||||
setOpen(false);
|
||||
await utils.sso.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to register SAML provider",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Register SAML provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
|
||||
OneLogin). You need the IdP's SSO URL and signing certificate.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g. okta-saml or azure-saml"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issuer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issuer URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://idp.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Domains</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => append("")}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Add domain
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Email domains that use this provider (sign-in by email and org
|
||||
assignment; subdomains matched automatically).
|
||||
</FormDescription>
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`domains.${index}`}
|
||||
render={({ field: inputField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="company.com"
|
||||
className="flex-1"
|
||||
{...inputField}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
disabled={fields.length <= 1}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const err = form.formState.errors.domains;
|
||||
const msg =
|
||||
typeof err?.message === "string"
|
||||
? err.message
|
||||
: (err as { root?: { message?: string } } | undefined)?.root
|
||||
?.message;
|
||||
return msg ? (
|
||||
<p className="text-sm font-medium text-destructive">{msg}</p>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="entryPoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IdP SSO URL (Entry point)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://idp.example.com/sso"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Single Sign-On URL from your IdP's SAML setup.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cert"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IdP signing certificate (X.509)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Paste IdP signing certificate (PEM, BEGIN CERTIFICATE / END CERTIFICATE)"
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="idpMetadataXml"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IdP metadata XML (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Paste full IdP metadata XML if you have it (EntityDescriptor). Otherwise leave empty and use Issuer, IdP SSO URL and certificate above."
|
||||
rows={5}
|
||||
className="font-mono text-xs"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Some IdPs require full metadata; paste the XML here to
|
||||
override issuer/entry point/cert.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Register provider
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2, LogIn } from "lucide-react";
|
||||
import { 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,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const ssoEmailSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "Enter your work email")
|
||||
.email("Enter a valid email address")
|
||||
.transform((v) => v.trim()),
|
||||
});
|
||||
|
||||
type SSOEmailForm = z.infer<typeof ssoEmailSchema>;
|
||||
|
||||
interface SignInWithSSOProps {
|
||||
/** Content shown when SSO is collapsed (e.g. email/password form) */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const form = useForm<SSOEmailForm>({
|
||||
resolver: zodResolver(ssoEmailSchema),
|
||||
defaultValues: { email: "" },
|
||||
});
|
||||
|
||||
const onSubmit = async (values: SSOEmailForm) => {
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.sso({
|
||||
email: values.email,
|
||||
callbackURL: "/dashboard/projects",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to sign in with SSO");
|
||||
return;
|
||||
}
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to sign in with SSO",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="you@company.com"
|
||||
className="flex-1"
|
||||
autoComplete="email"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Use email and password instead
|
||||
</button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Loader2, LogIn, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||
|
||||
type ProviderForDetails = {
|
||||
id: string | null;
|
||||
providerId: string;
|
||||
issuer: string;
|
||||
domain: string;
|
||||
oidcConfig: string | null;
|
||||
samlConfig: string | null;
|
||||
organizationId: string | null;
|
||||
};
|
||||
|
||||
function parseOidcConfig(config: string | null): {
|
||||
clientId?: string;
|
||||
scopes?: string[];
|
||||
} | null {
|
||||
if (!config) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(config) as {
|
||||
clientId?: string;
|
||||
scopes?: string[];
|
||||
};
|
||||
return { clientId: parsed.clientId, scopes: parsed.scopes };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSamlConfig(
|
||||
config: string | null,
|
||||
): { entryPoint?: string } | null {
|
||||
if (!config) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(config) as { entryPoint?: string };
|
||||
return { entryPoint: parsed.entryPoint };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const SSOSettings = () => {
|
||||
const utils = api.useUtils();
|
||||
const [detailsProvider, setDetailsProvider] =
|
||||
useState<ProviderForDetails | null>(null);
|
||||
const [baseURL, setBaseURL] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setBaseURL(window.location.origin);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
|
||||
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
|
||||
api.sso.deleteProvider.useMutation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<LogIn className="size-6 text-muted-foreground" />
|
||||
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Configure OIDC or SAML identity providers for enterprise sign-in.
|
||||
Users can sign in with their organization's IdP.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Loading providers...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{providers && providers.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RegisterOidcDialog>
|
||||
<Button variant="secondary" size="sm">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add OIDC provider
|
||||
</Button>
|
||||
</RegisterOidcDialog>
|
||||
<RegisterSamlDialog>
|
||||
<Button variant="secondary" size="sm">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add SAML provider
|
||||
</Button>
|
||||
</RegisterSamlDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providers && providers.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<span className="text-sm font-medium">Registered providers</span>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{providers.map((provider) => {
|
||||
const isOidc = !!provider.oidcConfig;
|
||||
const isSaml = !!provider.samlConfig;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={provider.id}
|
||||
className="overflow-hidden bg-background"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-base font-medium">
|
||||
{provider.providerId}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{provider.issuer}
|
||||
</CardDescription>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{provider.domain}
|
||||
</Badge>
|
||||
{isOidc && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
OIDC
|
||||
</Badge>
|
||||
)}
|
||||
{isSaml && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
SAML
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2 pt-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDetailsProvider({
|
||||
id: provider.id,
|
||||
providerId: provider.providerId,
|
||||
issuer: provider.issuer,
|
||||
domain: provider.domain,
|
||||
oidcConfig: provider.oidcConfig,
|
||||
samlConfig: provider.samlConfig,
|
||||
organizationId: provider.organizationId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Eye className="mr-1 size-3" />
|
||||
View details
|
||||
</Button>
|
||||
<DialogAction
|
||||
title="Remove SSO provider"
|
||||
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteProvider({
|
||||
providerId: provider.providerId,
|
||||
});
|
||||
toast.success("Provider removed");
|
||||
await utils.sso.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to remove provider",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3" />
|
||||
Remove
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 justify-center min-h-[30vh] text-center">
|
||||
<div className="flex flex-col items-center gap-2 max-w-[400px]">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<LogIn className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">No SSO providers</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add an OIDC or SAML provider so users can sign in with their
|
||||
organization's IdP (e.g. Okta, Azure AD).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<RegisterOidcDialog>
|
||||
<Button variant="secondary">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add OIDC provider
|
||||
</Button>
|
||||
</RegisterOidcDialog>
|
||||
<RegisterSamlDialog>
|
||||
<Button variant="outline">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add SAML provider
|
||||
</Button>
|
||||
</RegisterSamlDialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={!!detailsProvider}
|
||||
onOpenChange={(open) => !open && setDetailsProvider(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
{detailsProvider && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>SSO provider details</DialogTitle>
|
||||
<DialogDescription>
|
||||
View-only. To change settings, remove this provider and add it
|
||||
again with the new values.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</span>
|
||||
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
|
||||
{detailsProvider.providerId}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Issuer URL
|
||||
</span>
|
||||
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||
{detailsProvider.issuer}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Domain
|
||||
</span>
|
||||
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||
{detailsProvider.domain}
|
||||
</p>
|
||||
</div>
|
||||
{detailsProvider.oidcConfig && (
|
||||
<>
|
||||
{(() => {
|
||||
const oidc = parseOidcConfig(detailsProvider.oidcConfig);
|
||||
if (!oidc) return null;
|
||||
return (
|
||||
<>
|
||||
{oidc.clientId && (
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Client ID
|
||||
</span>
|
||||
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
|
||||
{oidc.clientId}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{oidc.scopes && oidc.scopes.length > 0 && (
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Scopes
|
||||
</span>
|
||||
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||
{oidc.scopes.join(" ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
{detailsProvider.samlConfig && (
|
||||
<>
|
||||
{(() => {
|
||||
const saml = parseSamlConfig(detailsProvider.samlConfig);
|
||||
if (!saml?.entryPoint) return null;
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Entry point
|
||||
</span>
|
||||
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||
{saml.entryPoint}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Callback URL (configure in your IdP)
|
||||
</span>
|
||||
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||
{baseURL || "{baseURL}"}
|
||||
{detailsProvider.samlConfig
|
||||
? "/api/auth/sso/saml2/callback/"
|
||||
: "/api/auth/sso/callback/"}
|
||||
{detailsProvider.providerId}
|
||||
</p>
|
||||
{!baseURL && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Replace {"{baseURL}"} with your Dokploy URL (e.g. https://
|
||||
your-domain.com).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDetailsProvider(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@ export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<Input ref={inputRef} {...props} type="password" />
|
||||
<Input ref={inputRef} type={"password"} {...props} />
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import * as React from "react";
|
||||
import { isSolidColorAvatar } from "@/lib/avatar-utils";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
@@ -20,33 +20,14 @@ Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & {
|
||||
src?: string | null;
|
||||
}
|
||||
>(({ className, src, ...props }, ref) => {
|
||||
if (isSolidColorAvatar(src)) {
|
||||
return (
|
||||
<div
|
||||
key={`solid-${src}`}
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full rounded-full", className)}
|
||||
style={{
|
||||
backgroundColor: src,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
src={src ?? ""}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
|
||||
@@ -1,75 +1,18 @@
|
||||
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { generateRandomPassword } from "@/lib/password-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
errorMessage?: string;
|
||||
enablePasswordGenerator?: boolean;
|
||||
passwordGeneratorLength?: number;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
errorMessage,
|
||||
type,
|
||||
enablePasswordGenerator = false,
|
||||
passwordGeneratorLength,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
({ className, errorMessage, type, ...props }, ref) => {
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const isPassword = type === "password";
|
||||
const shouldShowGenerator =
|
||||
isPassword &&
|
||||
enablePasswordGenerator !== false &&
|
||||
!props.disabled &&
|
||||
!props.readOnly;
|
||||
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 (
|
||||
<>
|
||||
<div className="relative w-full">
|
||||
@@ -78,39 +21,25 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
className={cn(
|
||||
// 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",
|
||||
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
|
||||
isPassword && "pr-10", // Add padding for the eye icon
|
||||
className,
|
||||
)}
|
||||
ref={setRefs}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
|
||||
{shouldShowGenerator && (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground focus:outline-none"
|
||||
onClick={handleGeneratePassword}
|
||||
aria-label="Generate password"
|
||||
title="Generate password"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground 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
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{errorMessage && (
|
||||
|
||||
16
apps/dokploy/drizzle/0057_damp_prism.sql
Normal file
16
apps/dokploy/drizzle/0057_damp_prism.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
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 $$;
|
||||
@@ -1,114 +0,0 @@
|
||||
CREATE TABLE "webServerSettings" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"serverIp" text,
|
||||
"certificateType" "certificateType" DEFAULT 'none' NOT NULL,
|
||||
"https" boolean DEFAULT false NOT NULL,
|
||||
"host" text,
|
||||
"letsEncryptEmail" text,
|
||||
"sshPrivateKey" text,
|
||||
"enableDockerCleanup" boolean DEFAULT true NOT NULL,
|
||||
"logCleanupCron" text DEFAULT '0 0 * * *',
|
||||
"metricsConfig" jsonb DEFAULT '{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb NOT NULL,
|
||||
"cleanupCacheApplications" boolean DEFAULT false NOT NULL,
|
||||
"cleanupCacheOnPreviews" boolean DEFAULT false NOT NULL,
|
||||
"cleanupCacheOnCompose" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Migrate data from user table to webServerSettings
|
||||
-- Get the owner user's data and insert into webServerSettings
|
||||
INSERT INTO "webServerSettings" (
|
||||
"id",
|
||||
"serverIp",
|
||||
"certificateType",
|
||||
"https",
|
||||
"host",
|
||||
"letsEncryptEmail",
|
||||
"sshPrivateKey",
|
||||
"enableDockerCleanup",
|
||||
"logCleanupCron",
|
||||
"metricsConfig",
|
||||
"cleanupCacheApplications",
|
||||
"cleanupCacheOnPreviews",
|
||||
"cleanupCacheOnCompose",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid()::text as "id",
|
||||
u."serverIp",
|
||||
COALESCE(u."certificateType", 'none') as "certificateType",
|
||||
COALESCE(u."https", false) as "https",
|
||||
u."host",
|
||||
u."letsEncryptEmail",
|
||||
u."sshPrivateKey",
|
||||
COALESCE(u."enableDockerCleanup", true) as "enableDockerCleanup",
|
||||
COALESCE(u."logCleanupCron", '0 0 * * *') as "logCleanupCron",
|
||||
COALESCE(
|
||||
u."metricsConfig",
|
||||
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb
|
||||
) as "metricsConfig",
|
||||
COALESCE(u."cleanupCacheApplications", false) as "cleanupCacheApplications",
|
||||
COALESCE(u."cleanupCacheOnPreviews", false) as "cleanupCacheOnPreviews",
|
||||
COALESCE(u."cleanupCacheOnCompose", false) as "cleanupCacheOnCompose",
|
||||
NOW() as "created_at",
|
||||
NOW() as "updated_at"
|
||||
FROM "user" u
|
||||
INNER JOIN "member" m ON u."id" = m."user_id"
|
||||
WHERE m."role" = 'owner'
|
||||
ORDER BY m."created_at" ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- If no owner found, create a default entry
|
||||
INSERT INTO "webServerSettings" (
|
||||
"id",
|
||||
"serverIp",
|
||||
"certificateType",
|
||||
"https",
|
||||
"host",
|
||||
"letsEncryptEmail",
|
||||
"sshPrivateKey",
|
||||
"enableDockerCleanup",
|
||||
"logCleanupCron",
|
||||
"metricsConfig",
|
||||
"cleanupCacheApplications",
|
||||
"cleanupCacheOnPreviews",
|
||||
"cleanupCacheOnCompose",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid()::text as "id",
|
||||
NULL as "serverIp",
|
||||
'none' as "certificateType",
|
||||
false as "https",
|
||||
NULL as "host",
|
||||
NULL as "letsEncryptEmail",
|
||||
NULL as "sshPrivateKey",
|
||||
true as "enableDockerCleanup",
|
||||
'0 0 * * *' as "logCleanupCron",
|
||||
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb as "metricsConfig",
|
||||
false as "cleanupCacheApplications",
|
||||
false as "cleanupCacheOnPreviews",
|
||||
false as "cleanupCacheOnCompose",
|
||||
NOW() as "created_at",
|
||||
NOW() as "updated_at"
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "webServerSettings"
|
||||
);
|
||||
|
||||
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "serverIp";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "certificateType";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "https";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "host";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "letsEncryptEmail";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "sshPrivateKey";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "enableDockerCleanup";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "logCleanupCron";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "metricsConfig";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "cleanupCacheApplications";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnPreviews";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnCompose";
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "application" ALTER COLUMN "railpackVersion" SET DEFAULT '0.15.4';
|
||||
@@ -1,12 +0,0 @@
|
||||
ALTER TYPE "public"."notificationType" ADD VALUE 'pushover' BEFORE 'custom';--> statement-breakpoint
|
||||
CREATE TABLE "pushover" (
|
||||
"pushoverId" text PRIMARY KEY NOT NULL,
|
||||
"userKey" text NOT NULL,
|
||||
"apiToken" text NOT NULL,
|
||||
"priority" integer DEFAULT 0 NOT NULL,
|
||||
"retry" integer,
|
||||
"expire" integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD COLUMN "pushoverId" text;--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_pushoverId_pushover_pushoverId_fk" FOREIGN KEY ("pushoverId") REFERENCES "public"."pushover"("pushoverId") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE "application" ADD COLUMN "bitbucketRepositorySlug" text;--> statement-breakpoint
|
||||
ALTER TABLE "compose" ADD COLUMN "bitbucketRepositorySlug" text;
|
||||
@@ -1,18 +0,0 @@
|
||||
CREATE TABLE "sso_provider" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"oidc_config" text,
|
||||
"saml_config" text,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text,
|
||||
"organization_id" text,
|
||||
"domain" text NOT NULL,
|
||||
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "licenseKey" text;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "trustedOrigins" text[];--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1,10 +0,0 @@
|
||||
ALTER TYPE "public"."notificationType" ADD VALUE 'resend' BEFORE 'gotify';--> statement-breakpoint
|
||||
CREATE TABLE "resend" (
|
||||
"resendId" text PRIMARY KEY NOT NULL,
|
||||
"apiKey" text NOT NULL,
|
||||
"fromAddress" text NOT NULL,
|
||||
"toAddress" text[] NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD COLUMN "resendId" text;--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_resendId_resend_resendId_fk" FOREIGN KEY ("resendId") REFERENCES "public"."resend"("resendId") ON DELETE cascade ON UPDATE no action;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "invitation" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user