mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Compare commits
322 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
839e1c0f9f | ||
|
|
54dd531a26 | ||
|
|
7ebf5ad0f9 | ||
|
|
b85163d935 | ||
|
|
a953e59327 | ||
|
|
b2661e4533 | ||
|
|
883459624e | ||
|
|
6e2b2d564b | ||
|
|
065963857c | ||
|
|
a0c9df4bd4 | ||
|
|
68c8c70260 | ||
|
|
a926f28d30 | ||
|
|
59c0636fb0 | ||
|
|
ae159c5678 | ||
|
|
e42e9bec17 | ||
|
|
978324e2bf | ||
|
|
8f05f06259 | ||
|
|
392be2cfa2 | ||
|
|
18e89df9a5 | ||
|
|
4d2a9f8aa7 | ||
|
|
d08530d451 | ||
|
|
6c9b12cee9 | ||
|
|
a8ff6c7b3f | ||
|
|
8699e024ee | ||
|
|
73782ffd26 | ||
|
|
7a8bb8f71d | ||
|
|
18eae9f7d7 | ||
|
|
1aae523a0b | ||
|
|
f40e802331 | ||
|
|
d979aa17c2 | ||
|
|
e2d20fb0e3 | ||
|
|
62f59c1f9a | ||
|
|
93e1071057 | ||
|
|
788771c5eb | ||
|
|
ab9aa56c48 | ||
|
|
4565b3d7a2 | ||
|
|
c8514e3a1b | ||
|
|
a06dd17aa1 | ||
|
|
256534570b | ||
|
|
2804748118 | ||
|
|
e6bc40e7fe | ||
|
|
196603126b | ||
|
|
a5cd8f18cd | ||
|
|
b842887bc3 | ||
|
|
dd64b06340 | ||
|
|
d9a1976cc0 | ||
|
|
fdfa927532 | ||
|
|
bf2551b0f6 | ||
|
|
ed8be62ff3 | ||
|
|
77336a21f9 | ||
|
|
e05d01788f | ||
|
|
651e81ce6d | ||
|
|
fac29b70a5 | ||
|
|
95eaab43df | ||
|
|
abdef13b93 | ||
|
|
65f397e1b1 | ||
|
|
1ae96297e8 | ||
|
|
c51b502116 | ||
|
|
5a42b78098 | ||
|
|
b39c0ef915 | ||
|
|
844d582147 | ||
|
|
0b51088489 | ||
|
|
1dece58cff | ||
|
|
d22330f983 | ||
|
|
852895c382 | ||
|
|
20d5913820 | ||
|
|
f1b4a73158 | ||
|
|
3830f6c4ee | ||
|
|
5c8eda2405 | ||
|
|
6bf85bcfa3 | ||
|
|
bc03e718bf | ||
|
|
a941efb1ff | ||
|
|
fe2de6b899 | ||
|
|
b3313cf975 | ||
|
|
4e31d8ac02 | ||
|
|
536507377d | ||
|
|
6db9c99080 | ||
|
|
7e8953ff44 | ||
|
|
81c85ce155 | ||
|
|
bd16e03602 | ||
|
|
87a5ce2053 | ||
|
|
ca4820940e | ||
|
|
71fe6de9cb | ||
|
|
9ff4968e61 | ||
|
|
2312ae1c12 | ||
|
|
b03011a94f | ||
|
|
7577e40b25 | ||
|
|
75e34285ef | ||
|
|
038df9c8a7 | ||
|
|
829aa2a63c | ||
|
|
91e90fc379 | ||
|
|
a1e13ee964 | ||
|
|
341af1bd07 | ||
|
|
8a274d10eb | ||
|
|
6c586f9606 | ||
|
|
dcb1ea37c3 | ||
|
|
58c2ceb355 | ||
|
|
beae03b53d | ||
|
|
55ec25f5e8 | ||
|
|
9382acb40c | ||
|
|
c0acdc5df1 | ||
|
|
413536a336 | ||
|
|
190f45b3a8 | ||
|
|
e6c242a064 | ||
|
|
c2fe1eed01 | ||
|
|
676082fc5b | ||
|
|
b676b1a2de | ||
|
|
5885712c6a | ||
|
|
afedeede16 | ||
|
|
5f09018199 | ||
|
|
9d37876bc4 | ||
|
|
775107ec24 | ||
|
|
5f297fd984 | ||
|
|
86aba9ce3e | ||
|
|
c6e512bec1 | ||
|
|
fc2b0abdb1 | ||
|
|
d20f86ffe1 | ||
|
|
1157e08aa1 | ||
|
|
e643255a67 | ||
|
|
7521bc8297 | ||
|
|
a63981fa15 | ||
|
|
ea0f797d0f | ||
|
|
181a2ca3c9 | ||
|
|
3fe057c7f8 | ||
|
|
1e834ed1d9 | ||
|
|
9f84545fc7 | ||
|
|
690a2e7467 | ||
|
|
995d9004f3 | ||
|
|
0a3ab7ceac | ||
|
|
2fd4d580d5 | ||
|
|
33e2fa3ce3 | ||
|
|
d320847da4 | ||
|
|
9e84bf324e | ||
|
|
db469e60ad | ||
|
|
0f949b3273 | ||
|
|
166b65c50e | ||
|
|
274c65cbcd | ||
|
|
b538a632d9 | ||
|
|
765c6442cb | ||
|
|
115ed7e7bf | ||
|
|
0644842305 | ||
|
|
a9c62b47ef | ||
|
|
138650d561 | ||
|
|
280be5c9df | ||
|
|
7726fa6112 | ||
|
|
c71d12fd06 | ||
|
|
3df3d187e4 | ||
|
|
8ce9db8dd6 | ||
|
|
6773458da3 | ||
|
|
e5d5a98bab | ||
|
|
4311ba93f3 | ||
|
|
e0b596ec76 | ||
|
|
379ba20930 | ||
|
|
236e511adc | ||
|
|
0b37e171c5 | ||
|
|
1df1e7b50b | ||
|
|
f15a5bc22d | ||
|
|
469871d383 | ||
|
|
e22b6ab9be | ||
|
|
b01b05077d | ||
|
|
22122361ba | ||
|
|
87c1ce68b9 | ||
|
|
4c8619677b | ||
|
|
7f705e31d3 | ||
|
|
20432ebc3f | ||
|
|
a51a7a82d2 | ||
|
|
5ba19686c8 | ||
|
|
22a2e64563 | ||
|
|
37ee89e6ab | ||
|
|
cb487b8be0 | ||
|
|
26f8719e5f | ||
|
|
3bc1bd5b15 | ||
|
|
ee622b1ba0 | ||
|
|
fe088bad3b | ||
|
|
d374f5eedf | ||
|
|
1c498ee2d2 | ||
|
|
d7e5eb6dfd | ||
|
|
f71e04eaaa | ||
|
|
fc9808e295 | ||
|
|
bc2a286e1d | ||
|
|
6c582eb91d | ||
|
|
e3b2a401a7 | ||
|
|
6c55143e96 | ||
|
|
19a0550b32 | ||
|
|
bb31bef8bc | ||
|
|
abc606d8d9 | ||
|
|
749dd03fe6 | ||
|
|
858d7e5c11 | ||
|
|
079b7b8e72 | ||
|
|
179f3818f0 | ||
|
|
8546031df0 | ||
|
|
16ca198eb4 | ||
|
|
9b5b452d90 | ||
|
|
2fa6f3bfa6 | ||
|
|
42f3105f69 | ||
|
|
a08ba7e8b5 | ||
|
|
a51ada4a1e | ||
|
|
50b1de9594 | ||
|
|
cb90281583 | ||
|
|
20b253e708 | ||
|
|
9a51e0a00d | ||
|
|
49b812e462 | ||
|
|
7233667d49 | ||
|
|
95cd410825 | ||
|
|
5b8ebdaaa4 | ||
|
|
343d5ae6a2 | ||
|
|
e16ce0c817 | ||
|
|
3b2440b1db | ||
|
|
2f72ccbea7 | ||
|
|
be47f6d09a | ||
|
|
9a65bf8e21 | ||
|
|
15959fa91f | ||
|
|
f0c14d144c | ||
|
|
725b763aa8 | ||
|
|
c0bfd7dde7 | ||
|
|
31ba5a784d | ||
|
|
00f9e262a9 | ||
|
|
54b6a850b7 | ||
|
|
029cbf4498 | ||
|
|
8a1cba470c | ||
|
|
84bb98c7e6 | ||
|
|
cbf0f37a49 | ||
|
|
2c22aa3689 | ||
|
|
46289305e8 | ||
|
|
0b3e15aabc | ||
|
|
69a3583717 | ||
|
|
bc55fde6d6 | ||
|
|
1cb1da8097 | ||
|
|
0698ac8318 | ||
|
|
7ced6840fa | ||
|
|
22e6d07f60 | ||
|
|
6a9690fe3c | ||
|
|
1c02478688 | ||
|
|
bbe72ad584 | ||
|
|
83b3176f6f | ||
|
|
7ecd1627c8 | ||
|
|
96fbfa7ef5 | ||
|
|
6874ede933 | ||
|
|
012f8ff2f5 | ||
|
|
9a7ed91a55 | ||
|
|
13e9a50959 | ||
|
|
d424ed23f5 | ||
|
|
bf9abbc37c | ||
|
|
b66e8c8855 | ||
|
|
ce0e9ccddc | ||
|
|
e03aef8e37 | ||
|
|
d0be2a2090 | ||
|
|
30eb1719b0 | ||
|
|
9e7299517f | ||
|
|
cd061916df | ||
|
|
6f818a833c | ||
|
|
00764ffd43 | ||
|
|
e2a16a5723 | ||
|
|
73b622eb2f | ||
|
|
cf3cea6146 | ||
|
|
b8e41e970d | ||
|
|
60b0ae5053 | ||
|
|
ff8263d8f6 | ||
|
|
f65822ca7e | ||
|
|
3feead31d9 | ||
|
|
b1fd1fb306 | ||
|
|
46cd22038b | ||
|
|
5058d9b47d | ||
|
|
ddf95d87bd | ||
|
|
28a5be984d | ||
|
|
1650f5ca79 | ||
|
|
e023cad72d | ||
|
|
49559ebee6 | ||
|
|
6cf0ecf016 | ||
|
|
1562396339 | ||
|
|
f94ee8c299 | ||
|
|
f47335efe5 | ||
|
|
c8b5889414 | ||
|
|
124d81bb1c | ||
|
|
5f987d28c7 | ||
|
|
32b19a0fb6 | ||
|
|
5f71a393be | ||
|
|
f4bd729f65 | ||
|
|
3320e21958 | ||
|
|
2960d81829 | ||
|
|
1c1e52f777 | ||
|
|
64e6919211 | ||
|
|
a53daed434 | ||
|
|
791c8afab7 | ||
|
|
4c45be1447 | ||
|
|
1056810170 | ||
|
|
b3ca81d2e8 | ||
|
|
9d170e5f46 | ||
|
|
3d0b6eb368 | ||
|
|
5db407b674 | ||
|
|
556a847054 | ||
|
|
4c34643287 | ||
|
|
5e590c1ce8 | ||
|
|
2f15f34a19 | ||
|
|
7f53e9cf07 | ||
|
|
48e3d48ab4 | ||
|
|
b9faf4bd1a | ||
|
|
27f43e774a | ||
|
|
c9d3616088 | ||
|
|
7fe8cd03bf | ||
|
|
38e5d244fe | ||
|
|
14573f90f7 | ||
|
|
cbbbe44802 | ||
|
|
00c7ae3f40 | ||
|
|
f10eae40c7 | ||
|
|
df63182f39 | ||
|
|
626bf7c41b | ||
|
|
75abc4758a | ||
|
|
c4c4b459cc | ||
|
|
d8787ec11d | ||
|
|
40c97b8e9c | ||
|
|
fd0a472468 | ||
|
|
db27ec0372 | ||
|
|
841b264257 | ||
|
|
5f6516ab7d | ||
|
|
2daa159e29 | ||
|
|
262a2394a9 | ||
|
|
e7383e1323 | ||
|
|
9a8a40b0f8 | ||
|
|
bcf1ba242e | ||
|
|
a235815a13 | ||
|
|
57594ecb0c |
@@ -99,14 +99,14 @@ workflows:
|
||||
only:
|
||||
- main
|
||||
- canary
|
||||
- fix/build-i18n
|
||||
- fix/nixpacks-version
|
||||
- build-arm64:
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- main
|
||||
- canary
|
||||
- fix/build-i18n
|
||||
- fix/nixpacks-version
|
||||
- combine-manifests:
|
||||
requires:
|
||||
- build-amd64
|
||||
@@ -116,4 +116,4 @@ workflows:
|
||||
only:
|
||||
- main
|
||||
- canary
|
||||
- fix/build-i18n
|
||||
- fix/nixpacks-version
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,7 +34,6 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Misc
|
||||
|
||||
@@ -241,7 +241,7 @@ export function generate(schema: Schema): Template {
|
||||
|
||||
- Use the same name of the folder as the id of the template.
|
||||
- The logo should be in the public folder.
|
||||
- If you want to show a domain in the UI, please add the prefix \_HOST at the end of the variable name.
|
||||
- If you want to show a domain in the UI, please add the `_HOST` suffix at the end of the variable name.
|
||||
- Test first on a vps or a server to make sure the template works.
|
||||
|
||||
## Docs & Website
|
||||
|
||||
@@ -35,7 +35,6 @@ RUN apt-get update && apt-get install -y curl unzip apache2-utils && rm -rf /var
|
||||
COPY --from=build /prod/dokploy/.next ./.next
|
||||
COPY --from=build /prod/dokploy/dist ./dist
|
||||
COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs
|
||||
COPY --from=build /prod/dokploy/next-i18next.config.cjs ./next-i18next.config.cjs
|
||||
COPY --from=build /prod/dokploy/public ./public
|
||||
COPY --from=build /prod/dokploy/package.json ./package.json
|
||||
COPY --from=build /prod/dokploy/drizzle ./drizzle
|
||||
@@ -49,6 +48,8 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
|
||||
|
||||
# Install Nixpacks and tsx
|
||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||
|
||||
ARG NIXPACKS_VERSION=1.29.1
|
||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||
&& chmod +x install.sh \
|
||||
&& ./install.sh \
|
||||
|
||||
@@ -44,7 +44,6 @@ RUN apt-get update && apt-get install -y curl unzip apache2-utils && rm -rf /var
|
||||
COPY --from=build /prod/dokploy/.next ./.next
|
||||
COPY --from=build /prod/dokploy/dist ./dist
|
||||
COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs
|
||||
COPY --from=build /prod/dokploy/next-i18next.config.cjs ./next-i18next.config.cjs
|
||||
COPY --from=build /prod/dokploy/public ./public
|
||||
COPY --from=build /prod/dokploy/package.json ./package.json
|
||||
COPY --from=build /prod/dokploy/drizzle ./drizzle
|
||||
|
||||
@@ -17,10 +17,10 @@ See the License for the specific language governing permissions and limitations
|
||||
|
||||
## Additional Terms for Specific Features
|
||||
|
||||
The following additional terms apply to the multi-node support, Docker Compose file and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||
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 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 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 and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, 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, 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, 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.
|
||||
|
||||
@@ -19,6 +19,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
||||
applicationType: z.literal("compose"),
|
||||
serverId: z.string().min(1),
|
||||
}),
|
||||
z.object({
|
||||
applicationId: z.string(),
|
||||
previewDeploymentId: z.string(),
|
||||
titleLog: z.string(),
|
||||
descriptionLog: z.string(),
|
||||
server: z.boolean().optional(),
|
||||
type: z.enum(["deploy"]),
|
||||
applicationType: z.literal("application-preview"),
|
||||
serverId: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type DeployJob = z.infer<typeof deployJobSchema>;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
deployRemoteApplication,
|
||||
deployRemoteCompose,
|
||||
deployRemotePreviewApplication,
|
||||
rebuildRemoteApplication,
|
||||
rebuildRemoteCompose,
|
||||
updateApplicationStatus,
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
} from "@dokploy/server";
|
||||
import type { DeployJob } from "./schema";
|
||||
|
||||
@@ -47,6 +49,20 @@ export const deploy = async (job: DeployJob) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (job.applicationType === "application-preview") {
|
||||
await updatePreviewDeployment(job.previewDeploymentId, {
|
||||
previewStatus: "running",
|
||||
});
|
||||
if (job.server) {
|
||||
if (job.type === "deploy") {
|
||||
await deployRemotePreviewApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog,
|
||||
descriptionLog: job.descriptionLog,
|
||||
previewDeploymentId: job.previewDeploymentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (job.applicationType === "application") {
|
||||
@@ -55,6 +71,10 @@ export const deploy = async (job: DeployJob) => {
|
||||
await updateCompose(job.composeId, {
|
||||
composeStatus: "error",
|
||||
});
|
||||
} else if (job.applicationType === "application-preview") {
|
||||
await updatePreviewDeployment(job.previewDeploymentId, {
|
||||
previewStatus: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ See the License for the specific language governing permissions and limitations
|
||||
|
||||
## Additional Terms for Specific Features
|
||||
|
||||
The following additional terms apply to the multi-node support, Docker Compose file and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||
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 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 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 and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, 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, 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, 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.
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("createDomainLabels", () => {
|
||||
domainId: "",
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
previewDeploymentId: "",
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ if (typeof window === "undefined") {
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
herokuVersion: "",
|
||||
applicationStatus: "done",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
@@ -33,6 +34,15 @@ const baseApp: ApplicationNested = {
|
||||
registryUrl: "",
|
||||
branch: null,
|
||||
dockerBuildStage: "",
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
previewCertificateType: "none",
|
||||
previewEnv: null,
|
||||
previewHttps: false,
|
||||
previewPath: "/",
|
||||
previewPort: 3000,
|
||||
previewLimit: 0,
|
||||
previewWildcard: "",
|
||||
project: {
|
||||
env: "",
|
||||
adminId: "",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expect, test } from "vitest";
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
herokuVersion: "",
|
||||
applicationStatus: "done",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
@@ -14,6 +15,15 @@ const baseApp: ApplicationNested = {
|
||||
dockerBuildStage: "",
|
||||
registryUrl: "",
|
||||
buildArgs: null,
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
previewCertificateType: "none",
|
||||
previewEnv: null,
|
||||
previewHttps: false,
|
||||
previewPath: "/",
|
||||
previewPort: 3000,
|
||||
previewLimit: 0,
|
||||
previewWildcard: "",
|
||||
project: {
|
||||
env: "",
|
||||
adminId: "",
|
||||
@@ -95,6 +105,7 @@ const baseDomain: Domain = {
|
||||
composeId: "",
|
||||
domainType: "application",
|
||||
uniqueConfigKey: 1,
|
||||
previewDeploymentId: "",
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
|
||||
@@ -87,7 +87,7 @@ export const Login2FA = ({ authId }: Props) => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<CardTitle className="text-xl font-bold">2FA Setup</CardTitle>
|
||||
<CardTitle className="text-xl font-bold">2FA Login</CardTitle>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,7 +19,6 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -150,7 +150,7 @@ export const AddVolumes = ({
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button>{children}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Volumes / Mounts</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -303,9 +303,12 @@ export const AddVolumes = ({
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Any content"
|
||||
className="h-64"
|
||||
<CodeEditor
|
||||
language="properties"
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
className="h-96 font-mono"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Pencil } from "lucide-react";
|
||||
@@ -119,7 +118,7 @@ export const UpdateVolume = ({
|
||||
} else if (typeForm === "file") {
|
||||
form.reset({
|
||||
content: data.content || "",
|
||||
mountPath: "/",
|
||||
mountPath: serviceType === "compose" ? "/" : data.mountPath,
|
||||
filePath: data.filePath || "",
|
||||
type: "file",
|
||||
});
|
||||
@@ -182,7 +181,7 @@ export const UpdateVolume = ({
|
||||
<Pencil className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update</DialogTitle>
|
||||
<DialogDescription>Update the mount</DialogDescription>
|
||||
@@ -247,9 +246,12 @@ export const UpdateVolume = ({
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Any content"
|
||||
className="h-64"
|
||||
<CodeEditor
|
||||
language="properties"
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
className="h-96 font-mono"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -41,6 +41,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("heroku_buildpacks"),
|
||||
herokuVersion: z.string().nullable().default(""),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("paketo_buildpacks"),
|
||||
@@ -90,6 +91,13 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
dockerBuildStage: data.dockerBuildStage || "",
|
||||
}),
|
||||
});
|
||||
} else if (data.buildType === "heroku_buildpacks") {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
...(data.buildType && {
|
||||
herokuVersion: data.herokuVersion || "",
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
@@ -110,6 +118,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
data.buildType === "dockerfile" ? data.dockerContextPath : null,
|
||||
dockerBuildStage:
|
||||
data.buildType === "dockerfile" ? data.dockerBuildStage : null,
|
||||
herokuVersion:
|
||||
data.buildType === "heroku_buildpacks" ? data.herokuVersion : null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Build type saved");
|
||||
@@ -200,6 +210,28 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{buildType === "heroku_buildpacks" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="herokuVersion"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Heroku Version (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"Heroku Version (Default: 24)"}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{buildType === "dockerfile" && (
|
||||
<>
|
||||
<FormField
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { Copy, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -102,9 +103,26 @@ export const DeleteApplication = ({ applicationId }: Props) => {
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<span>
|
||||
To confirm, type{" "}
|
||||
<Badge
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (data?.name && data?.appName) {
|
||||
navigator.clipboard.writeText(
|
||||
`${data.name}/${data.appName}`,
|
||||
);
|
||||
toast.success("Copied to clipboard. Be careful!");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{data?.name}/{data?.appName}
|
||||
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||
</Badge>{" "}
|
||||
in the box below:
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TerminalLine } from "../../docker/logs/terminal-line";
|
||||
import { LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
logPath: string | null;
|
||||
@@ -15,9 +19,26 @@ interface Props {
|
||||
}
|
||||
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
|
||||
const [data, setData] = useState("");
|
||||
const endOfLogsRef = useRef<HTMLDivElement>(null);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||
setAutoScroll(isAtBottom);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !logPath) return;
|
||||
|
||||
@@ -48,13 +69,20 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
|
||||
};
|
||||
}, [logPath, open]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const logs = parseLogs(data);
|
||||
setFilteredLogs(logs);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [data]);
|
||||
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [filteredLogs, autoScroll]);
|
||||
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -76,17 +104,27 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deployment</DialogTitle>
|
||||
<DialogDescription>
|
||||
See all the details of this deployment
|
||||
See all the details of this deployment | <Badge variant="blank" className="text-xs">{filteredLogs.length} lines</Badge>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
|
||||
<code>
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
{data || "Loading..."}
|
||||
</pre>
|
||||
<div ref={endOfLogsRef} />
|
||||
</code>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||
> {
|
||||
filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => (
|
||||
<TerminalLine
|
||||
key={index}
|
||||
log={log}
|
||||
noTimestamp
|
||||
/>
|
||||
)) :
|
||||
(
|
||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
||||
refetchInterval: 1000,
|
||||
},
|
||||
);
|
||||
|
||||
const [url, setUrl] = React.useState("");
|
||||
useEffect(() => {
|
||||
setUrl(document.location.origin);
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex w-full flex-col gap-5 "
|
||||
>
|
||||
<Card className="bg-background">
|
||||
<Card className="bg-background p-6">
|
||||
<Secrets
|
||||
name="env"
|
||||
title="Environment Settings"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { useRouter } from "next/router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
@@ -18,6 +19,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const DeployApplication = ({ applicationId }: Props) => {
|
||||
const router = useRouter();
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -51,6 +53,9 @@ export const DeployApplication = ({ applicationId }: Props) => {
|
||||
.then(async () => {
|
||||
toast.success("Application deployed succesfully");
|
||||
await refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
|
||||
.catch(() => {
|
||||
|
||||
@@ -2,9 +2,9 @@ import { ShowBuildChooseForm } from "@/components/dashboard/application/build/sh
|
||||
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { CheckCircle2, Terminal } from "lucide-react";
|
||||
import { Terminal } from "lucide-react";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
@@ -39,27 +39,6 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
appName={data?.appName || ""}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
aria-label="Toggle italic"
|
||||
pressed={data?.autoDeploy || false}
|
||||
onPressedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center"
|
||||
>
|
||||
Autodeploy
|
||||
{data?.autoDeploy && <CheckCircle2 className="size-4" />}
|
||||
</Toggle>
|
||||
<RedbuildApplication applicationId={applicationId} />
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
<StartApplication applicationId={applicationId} />
|
||||
@@ -75,6 +54,27 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle italic"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ShowProviderForm applicationId={applicationId} />
|
||||
|
||||
@@ -90,7 +90,6 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
</Select>
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
id="terminal"
|
||||
containerId={containerId || "select-a-container"}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input, NumberInput } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { domain } from "@/server/db/validations/domain";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dices } from "lucide-react";
|
||||
import type z from "zod";
|
||||
|
||||
type Domain = z.infer<typeof domain>;
|
||||
|
||||
interface Props {
|
||||
previewDeploymentId: string;
|
||||
domainId?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AddPreviewDomain = ({
|
||||
previewDeploymentId,
|
||||
domainId = "",
|
||||
children,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { data, refetch } = api.domain.one.useQuery(
|
||||
{
|
||||
domainId,
|
||||
},
|
||||
{
|
||||
enabled: !!domainId,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: previewDeployment } = api.previewDeployment.one.useQuery(
|
||||
{
|
||||
previewDeploymentId,
|
||||
},
|
||||
{
|
||||
enabled: !!previewDeploymentId,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } = domainId
|
||||
? api.domain.update.useMutation()
|
||||
: api.domain.create.useMutation();
|
||||
|
||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const form = useForm<Domain>({
|
||||
resolver: zodResolver(domain),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
...data,
|
||||
/* Convert null to undefined */
|
||||
path: data?.path || undefined,
|
||||
port: data?.port || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!domainId) {
|
||||
form.reset({});
|
||||
}
|
||||
}, [form, form.reset, data, isLoading]);
|
||||
|
||||
const dictionary = {
|
||||
success: domainId ? "Domain Updated" : "Domain Created",
|
||||
error: domainId
|
||||
? "Error to update the domain"
|
||||
: "Error to create the domain",
|
||||
submit: domainId ? "Update" : "Create",
|
||||
dialogDescription: domainId
|
||||
? "In this section you can edit a domain"
|
||||
: "In this section you can add domains",
|
||||
};
|
||||
|
||||
const onSubmit = async (data: Domain) => {
|
||||
await mutateAsync({
|
||||
domainId,
|
||||
previewDeploymentId,
|
||||
...data,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(dictionary.success);
|
||||
await utils.previewDeployment.all.invalidate({
|
||||
applicationId: previewDeployment?.applicationId,
|
||||
});
|
||||
|
||||
if (domainId) {
|
||||
refetch();
|
||||
}
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(dictionary.error);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger className="" asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domain</DialogTitle>
|
||||
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-8 "
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Host</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input placeholder="api.dokploy.com" {...field} />
|
||||
</FormControl>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingGenerate}
|
||||
onClick={() => {
|
||||
generateDomain({
|
||||
appName: previewDeployment?.appName || "",
|
||||
serverId:
|
||||
previewDeployment?.application
|
||||
?.serverId || "",
|
||||
})
|
||||
.then((domain) => {
|
||||
field.onChange(domain);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Dices className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>Generate traefik.me domain</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"/"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Container Port</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput placeholder={"3000"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="https"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically provision SSL Certificate.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.getValues().https && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Certificate</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Letsencrypt (Default)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button isLoading={isLoading} form="hook-form" type="submit">
|
||||
{dictionary.submit}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import type { RouterOutputs } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
import { ShowDeployment } from "../deployments/show-deployment";
|
||||
|
||||
interface Props {
|
||||
deployments: RouterOutputs["deployment"]["all"];
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => {
|
||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">View Builds</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Preview Builds</DialogTitle>
|
||||
<DialogDescription>
|
||||
See all the preview builds for this application on this Pull Request
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
{deployments?.map((deployment) => (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{deployment.status}
|
||||
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.title}
|
||||
</span>
|
||||
{deployment.description && (
|
||||
<span className="break-all text-sm text-muted-foreground">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment.logPath);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<ShowDeployment
|
||||
serverId={serverId || ""}
|
||||
open={activeLog !== null}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { Pencil, RocketIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ShowDeployment } from "../deployments/show-deployment";
|
||||
import Link from "next/link";
|
||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { AddPreviewDomain } from "./add-preview-domain";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { ShowPreviewSettings } from "./show-preview-settings";
|
||||
import { ShowPreviewBuilds } from "./show-preview-builds";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||
const { data } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||
api.previewDeployment.delete.useMutation();
|
||||
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
|
||||
api.previewDeployment.all.useQuery(
|
||||
{ applicationId },
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
);
|
||||
// const [url, setUrl] = React.useState("");
|
||||
// useEffect(() => {
|
||||
// setUrl(document.location.origin);
|
||||
// }, []);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl">Preview Deployments</CardTitle>
|
||||
<CardDescription>See all the preview deployments</CardDescription>
|
||||
</div>
|
||||
{data?.isPreviewDeploymentsActive && (
|
||||
<ShowPreviewSettings applicationId={applicationId} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{data?.isPreviewDeploymentsActive ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<span>
|
||||
Preview deployments are a way to test your application before it
|
||||
is deployed to production. It will create a new deployment for
|
||||
each pull request you create.
|
||||
</span>
|
||||
</div>
|
||||
{data?.previewDeployments?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<RocketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No preview deployments found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{previewDeployments?.map((previewDeployment) => {
|
||||
const { deployments, domain } = previewDeployment;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={previewDeployment?.previewDeploymentId}
|
||||
className="flex flex-col justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex justify-between gap-2 max-sm:flex-wrap">
|
||||
<div className="flex flex-col gap-2">
|
||||
{deployments?.length === 0 ? (
|
||||
<div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No deployments found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{previewDeployment?.pullRequestTitle}
|
||||
</span>
|
||||
<StatusTooltip
|
||||
status={previewDeployment.previewStatus}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{previewDeployment?.pullRequestTitle && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="break-all text-sm text-muted-foreground w-fit">
|
||||
Title: {previewDeployment?.pullRequestTitle}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewDeployment?.pullRequestURL && (
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubIcon />
|
||||
<Link
|
||||
target="_blank"
|
||||
href={previewDeployment?.pullRequestURL}
|
||||
className="break-all text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
|
||||
>
|
||||
Pull Request URL
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col ">
|
||||
<span>Domain </span>
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`http://${domain?.host}`}
|
||||
className="text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
|
||||
>
|
||||
{domain?.host}
|
||||
</Link>
|
||||
<AddPreviewDomain
|
||||
previewDeploymentId={
|
||||
previewDeployment.previewDeploymentId
|
||||
}
|
||||
domainId={domain?.domainId}
|
||||
>
|
||||
<Button variant="outline" size="sm">
|
||||
<Pencil className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AddPreviewDomain>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:items-end gap-2 max-sm:w-full">
|
||||
{previewDeployment?.createdAt && (
|
||||
<div className="text-sm capitalize text-muted-foreground">
|
||||
<DateTooltip
|
||||
date={previewDeployment?.createdAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ShowPreviewBuilds
|
||||
deployments={previewDeployment?.deployments || []}
|
||||
serverId={data?.serverId || ""}
|
||||
/>
|
||||
|
||||
<ShowModalLogs
|
||||
appName={previewDeployment.appName}
|
||||
serverId={data?.serverId || ""}
|
||||
>
|
||||
<Button variant="outline">View Logs</Button>
|
||||
</ShowModalLogs>
|
||||
|
||||
<DialogAction
|
||||
title="Delete Preview"
|
||||
description="Are you sure you want to delete this preview?"
|
||||
onClick={() => {
|
||||
deletePreviewDeployment({
|
||||
previewDeploymentId:
|
||||
previewDeployment.previewDeploymentId,
|
||||
})
|
||||
.then(() => {
|
||||
refetchPreviewDeployments();
|
||||
toast.success("Preview deployment deleted");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button variant="destructive" isLoading={isLoading}>
|
||||
Delete Preview
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<RocketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
Preview deployments are disabled for this application, please
|
||||
enable it
|
||||
</span>
|
||||
<ShowPreviewSettings applicationId={applicationId} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
import { api } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
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, NumberInput } from "@/components/ui/input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Secrets } from "@/components/ui/secrets";
|
||||
import { toast } from "sonner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const schema = z.object({
|
||||
env: z.string(),
|
||||
buildArgs: z.string(),
|
||||
wildcardDomain: z.string(),
|
||||
port: z.number(),
|
||||
previewLimit: z.number(),
|
||||
previewHttps: z.boolean(),
|
||||
previewPath: z.string(),
|
||||
previewCertificateType: z.enum(["letsencrypt", "none"]),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const { mutateAsync: updateApplication, isLoading } =
|
||||
api.application.update.useMutation();
|
||||
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
env: "",
|
||||
wildcardDomain: "*.traefik.me",
|
||||
port: 3000,
|
||||
previewLimit: 3,
|
||||
previewHttps: false,
|
||||
previewPath: "/",
|
||||
previewCertificateType: "none",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const previewHttps = form.watch("previewHttps");
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||
}, [data?.isPreviewDeploymentsActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
env: data.previewEnv || "",
|
||||
buildArgs: data.previewBuildArgs || "",
|
||||
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
||||
port: data.previewPort || 3000,
|
||||
previewLimit: data.previewLimit || 3,
|
||||
previewHttps: data.previewHttps || false,
|
||||
previewPath: data.previewPath || "/",
|
||||
previewCertificateType: data.previewCertificateType || "none",
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const onSubmit = async (formData: Schema) => {
|
||||
updateApplication({
|
||||
previewEnv: formData.env,
|
||||
previewBuildArgs: formData.buildArgs,
|
||||
previewWildcard: formData.wildcardDomain,
|
||||
previewPort: formData.port,
|
||||
applicationId,
|
||||
previewLimit: formData.previewLimit,
|
||||
previewHttps: formData.previewHttps,
|
||||
previewPath: formData.previewPath,
|
||||
previewCertificateType: formData.previewCertificateType,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Preview Deployments settings updated");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">View Settings</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Preview Deployment Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Adjust the settings for preview deployments of this application,
|
||||
including environment variables, build options, and deployment
|
||||
rules.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-delete-application"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wildcardDomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Wildcard Domain</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="*.traefik.me" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="previewPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Preview Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput placeholder="3000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="previewLimit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Preview Limit</FormLabel>
|
||||
{/* <FormDescription>
|
||||
Set the limit of preview deployments that can be
|
||||
created for this app.
|
||||
</FormDescription> */}
|
||||
<FormControl>
|
||||
<NumberInput placeholder="3000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="previewHttps"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically provision SSL Certificate.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{previewHttps && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="previewCertificateType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Certificate</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Letsencrypt (Default)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-row items-center justify-between rounded-lg border p-4 col-span-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Enable preview deployments
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Enable or disable preview deployments for this
|
||||
application.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
updateApplication({
|
||||
isPreviewDeploymentsActive: checked,
|
||||
applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Preview deployments enabled");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="env"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Secrets
|
||||
name="env"
|
||||
title="Environment Settings"
|
||||
description="You can add environment variables to your resource."
|
||||
placeholder={[
|
||||
"NODE_ENV=production",
|
||||
"PORT=3000",
|
||||
].join("\n")}
|
||||
/>
|
||||
{/* <CodeEditor
|
||||
lineWrapping
|
||||
language="properties"
|
||||
wrapperClassName="h-[25rem] font-mono"
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
|
||||
`}
|
||||
{...field}
|
||||
/> */}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
name="buildArgs"
|
||||
title="Build-time Variables"
|
||||
description={
|
||||
<span>
|
||||
Available only at build-time. See documentation
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.docker.com/build/guide/build-args/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
placeholder="NPM_TOKEN=xyz"
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-delete-application"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Copy } from "lucide-react";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
@@ -100,10 +102,27 @@ export const DeleteCompose = ({ composeId }: Props) => {
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
</FormLabel>{" "}
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<span>
|
||||
To confirm, type{" "}
|
||||
<Badge
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (data?.name && data?.appName) {
|
||||
navigator.clipboard.writeText(
|
||||
`${data.name}/${data.appName}`,
|
||||
);
|
||||
toast.success("Copied to clipboard. Be careful!");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{data?.name}/{data?.appName}
|
||||
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||
</Badge>{" "}
|
||||
in the box below:
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter compose name to confirm"
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TerminalLine } from "../../docker/logs/terminal-line";
|
||||
import { LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
|
||||
interface Props {
|
||||
logPath: string | null;
|
||||
@@ -20,9 +25,26 @@ export const ShowDeploymentCompose = ({
|
||||
serverId,
|
||||
}: Props) => {
|
||||
const [data, setData] = useState("");
|
||||
const endOfLogsRef = useRef<HTMLDivElement>(null);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||
setAutoScroll(isAtBottom);
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !logPath) return;
|
||||
|
||||
@@ -54,13 +76,19 @@ export const ShowDeploymentCompose = ({
|
||||
};
|
||||
}, [logPath, open]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const logs = parseLogs(data);
|
||||
setFilteredLogs(logs);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [data]);
|
||||
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [filteredLogs, autoScroll]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -78,21 +106,35 @@ export const ShowDeploymentCompose = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
|
||||
<DialogContent className={"sm:max-w-5xl max-h-screen"}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deployment</DialogTitle>
|
||||
<DialogDescription>
|
||||
See all the details of this deployment
|
||||
See all the details of this deployment | <Badge variant="blank" className="text-xs">{filteredLogs.length} lines</Badge>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
|
||||
<code>
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
{data || "Loading..."}
|
||||
</pre>
|
||||
<div ref={endOfLogsRef} />
|
||||
</code>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||
>
|
||||
|
||||
|
||||
{
|
||||
filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => (
|
||||
<TerminalLine
|
||||
key={index}
|
||||
log={log}
|
||||
noTimestamp
|
||||
/>
|
||||
)) :
|
||||
(
|
||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { CheckCircle2, ExternalLink, Globe, Terminal } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||
import { StartCompose } from "../start-compose";
|
||||
import { DeployCompose } from "./deploy-compose";
|
||||
import { RedbuildCompose } from "./rebuild-compose";
|
||||
import { StopCompose } from "./stop-compose";
|
||||
@@ -50,28 +51,11 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
||||
<DeployCompose composeId={composeId} />
|
||||
<Toggle
|
||||
aria-label="Toggle italic"
|
||||
pressed={data?.autoDeploy || false}
|
||||
onPressedChange={async (enabled) => {
|
||||
await update({
|
||||
composeId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center"
|
||||
>
|
||||
Autodeploy {data?.autoDeploy && <CheckCircle2 className="size-4" />}
|
||||
</Toggle>
|
||||
<RedbuildCompose composeId={composeId} />
|
||||
{data?.composeType === "docker-compose" && (
|
||||
{data?.composeType === "docker-compose" &&
|
||||
data?.composeStatus === "idle" ? (
|
||||
<StartCompose composeId={composeId} />
|
||||
) : (
|
||||
<StopCompose composeId={composeId} />
|
||||
)}
|
||||
|
||||
@@ -84,6 +68,27 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle italic"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
composeId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center"
|
||||
/>
|
||||
</div>
|
||||
{domains.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { useRouter } from "next/router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
@@ -18,6 +19,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const DeployCompose = ({ composeId }: Props) => {
|
||||
const router = useRouter();
|
||||
const { data, refetch } = api.compose.one.useQuery(
|
||||
{
|
||||
composeId,
|
||||
@@ -48,9 +50,15 @@ export const DeployCompose = ({ composeId }: Props) => {
|
||||
await refetch();
|
||||
await deploy({
|
||||
composeId,
|
||||
}).catch(() => {
|
||||
toast.error("Error to deploy Compose");
|
||||
});
|
||||
})
|
||||
.then(async () => {
|
||||
router.push(
|
||||
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to deploy Compose");
|
||||
});
|
||||
|
||||
await refetch();
|
||||
}}
|
||||
|
||||
@@ -96,7 +96,6 @@ export const ShowDockerLogsCompose = ({
|
||||
</Select>
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
id="terminal"
|
||||
containerId={containerId || "select-a-container"}
|
||||
/>
|
||||
</CardContent>
|
||||
|
||||
65
apps/dokploy/components/dashboard/compose/start-compose.tsx
Normal file
65
apps/dokploy/components/dashboard/compose/start-compose.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const StartCompose = ({ composeId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.compose.start.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="secondary" isLoading={isLoading}>
|
||||
Start
|
||||
<CheckCircle2 className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure to start the compose?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will start the compose
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.compose.one.invalidate({
|
||||
composeId,
|
||||
});
|
||||
toast.success("Compose started succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to start the Compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
65
apps/dokploy/components/dashboard/compose/stop-compose.tsx
Normal file
65
apps/dokploy/components/dashboard/compose/stop-compose.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { Ban } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const StopCompose = ({ composeId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.compose.stop.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" isLoading={isLoading}>
|
||||
Stop
|
||||
<Ban className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you absolutely sure to stop the compose?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will stop the compose
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.compose.one.invalidate({
|
||||
composeId,
|
||||
});
|
||||
toast.success("Compose stopped succesfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to stop the Compose");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -34,7 +35,7 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
|
||||
View Config
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className={"w-full md:w-[70vw] max-w-max"}>
|
||||
<DialogContent className={"w-full md:w-[70vw] min-w-[70vw]"}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Container Config</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -44,7 +45,13 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
|
||||
<div className="text-wrap rounded-lg border p-4 text-sm bg-card overflow-y-auto max-h-[80vh]">
|
||||
<code>
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
<CodeEditor
|
||||
language="json"
|
||||
lineWrapping
|
||||
lineNumbers={false}
|
||||
readOnly
|
||||
value={JSON.stringify(data, null, 2)}
|
||||
/>
|
||||
</pre>
|
||||
</code>
|
||||
</div>
|
||||
|
||||
@@ -1,114 +1,290 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { api } from "@/utils/api";
|
||||
import { Download as DownloadIcon, Loader2 } from "lucide-react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { LineCountFilter } from "./line-count-filter";
|
||||
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
||||
import { StatusLogsFilter } from "./status-logs-filter";
|
||||
import { TerminalLine } from "./terminal-line";
|
||||
import { type LogLine, getLogType, parseLogs } from "./utils";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
containerId: string;
|
||||
serverId?: string | null;
|
||||
}
|
||||
|
||||
export const DockerLogsId: React.FC<Props> = ({
|
||||
id,
|
||||
containerId,
|
||||
serverId,
|
||||
}) => {
|
||||
const [term, setTerm] = React.useState<Terminal>();
|
||||
const [lines, setLines] = React.useState<number>(40);
|
||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
||||
export const priorities = [
|
||||
{
|
||||
label: "Info",
|
||||
value: "info",
|
||||
},
|
||||
{
|
||||
label: "Success",
|
||||
value: "success",
|
||||
},
|
||||
{
|
||||
label: "Warning",
|
||||
value: "warning",
|
||||
},
|
||||
{
|
||||
label: "Debug",
|
||||
value: "debug",
|
||||
},
|
||||
{
|
||||
label: "Error",
|
||||
value: "error",
|
||||
},
|
||||
];
|
||||
|
||||
export const DockerLogsId: React.FC<Props> = ({ containerId, serverId }) => {
|
||||
const { data } = api.docker.getConfig.useQuery(
|
||||
{
|
||||
containerId,
|
||||
serverId: serverId ?? undefined,
|
||||
},
|
||||
{
|
||||
enabled: !!containerId,
|
||||
},
|
||||
);
|
||||
|
||||
const [rawLogs, setRawLogs] = React.useState("");
|
||||
const [filteredLogs, setFilteredLogs] = React.useState<LogLine[]>([]);
|
||||
const [autoScroll, setAutoScroll] = React.useState(true);
|
||||
const [lines, setLines] = React.useState<number>(100);
|
||||
const [search, setSearch] = React.useState<string>("");
|
||||
const [showTimestamp, setShowTimestamp] = React.useState(true);
|
||||
const [since, setSince] = React.useState<TimeFilter>("all");
|
||||
const [typeFilter, setTypeFilter] = React.useState<string[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||
setAutoScroll(isAtBottom);
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value || "");
|
||||
};
|
||||
|
||||
const handleLines = (lines: number) => {
|
||||
setRawLogs("");
|
||||
setFilteredLogs([]);
|
||||
setLines(lines);
|
||||
};
|
||||
|
||||
const handleSince = (value: TimeFilter) => {
|
||||
setRawLogs("");
|
||||
setFilteredLogs([]);
|
||||
setSince(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// if (containerId === "select-a-container") {
|
||||
// return;
|
||||
// }
|
||||
const container = document.getElementById(id);
|
||||
if (container) {
|
||||
container.innerHTML = "";
|
||||
}
|
||||
if (!containerId) return;
|
||||
|
||||
if (wsRef.current) {
|
||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
const termi = new Terminal({
|
||||
cursorBlink: true,
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
lineHeight: 1.25,
|
||||
fontWeight: 400,
|
||||
fontSize: 14,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
|
||||
convertEol: true,
|
||||
theme: {
|
||||
cursor: "transparent",
|
||||
background: "rgba(0, 0, 0, 0)",
|
||||
},
|
||||
});
|
||||
let isCurrentConnection = true;
|
||||
let noDataTimeout: NodeJS.Timeout;
|
||||
setIsLoading(true);
|
||||
setRawLogs("");
|
||||
setFilteredLogs([]);
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const params = new globalThis.URLSearchParams({
|
||||
containerId,
|
||||
tail: lines.toString(),
|
||||
since,
|
||||
search,
|
||||
});
|
||||
|
||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-logs?containerId=${containerId}&tail=${lines}${serverId ? `&serverId=${serverId}` : ""}`;
|
||||
if (serverId) {
|
||||
params.append("serverId", serverId);
|
||||
}
|
||||
|
||||
const wsUrl = `${protocol}//${
|
||||
window.location.host
|
||||
}/docker-container-logs?${params.toString()}`;
|
||||
console.log("Connecting to WebSocket:", wsUrl);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
const fitAddon = new FitAddon();
|
||||
termi.loadAddon(fitAddon);
|
||||
// @ts-ignore
|
||||
termi.open(container);
|
||||
fitAddon.fit();
|
||||
termi.focus();
|
||||
setTerm(termi);
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error: ", error);
|
||||
const resetNoDataTimeout = () => {
|
||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||
noDataTimeout = setTimeout(() => {
|
||||
if (isCurrentConnection) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 2000); // Wait 2 seconds for data before showing "No logs found"
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!isCurrentConnection) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
console.log("WebSocket connected");
|
||||
resetNoDataTimeout();
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
termi.write(e.data);
|
||||
if (!isCurrentConnection) return;
|
||||
setRawLogs((prev) => prev + e.data);
|
||||
setIsLoading(false);
|
||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
if (!isCurrentConnection) return;
|
||||
console.error("WebSocket error:", error);
|
||||
setIsLoading(false);
|
||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||
};
|
||||
|
||||
ws.onclose = (e) => {
|
||||
console.log(e.reason);
|
||||
|
||||
termi.write(`Connection closed!\nReason: ${e.reason}\n`);
|
||||
wsRef.current = null;
|
||||
if (!isCurrentConnection) return;
|
||||
console.log("WebSocket closed:", e.reason);
|
||||
setIsLoading(false);
|
||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
isCurrentConnection = false;
|
||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [lines, containerId]);
|
||||
}, [containerId, serverId, lines, search, since]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const logContent = filteredLogs
|
||||
.map(
|
||||
({ timestamp, message }: { timestamp: Date | null; message: string }) =>
|
||||
`${timestamp?.toISOString() || "No timestamp"} ${message}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const blob = new Blob([logContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
const appName = data.Name.replace("/", "") || "app";
|
||||
const isoDate = new Date().toISOString();
|
||||
a.href = url;
|
||||
a.download = `${appName}-${isoDate.slice(0, 10).replace(/-/g, "")}_${isoDate
|
||||
.slice(11, 19)
|
||||
.replace(/:/g, "")}.log.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleFilter = (logs: LogLine[]) => {
|
||||
return logs.filter((log) => {
|
||||
const logType = getLogType(log.message).type;
|
||||
|
||||
if (typeFilter.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeFilter.includes(logType);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
term?.clear();
|
||||
}, [lines, term]);
|
||||
setRawLogs("");
|
||||
setFilteredLogs([]);
|
||||
}, [containerId]);
|
||||
|
||||
useEffect(() => {
|
||||
const logs = parseLogs(rawLogs);
|
||||
const filtered = handleFilter(logs);
|
||||
setFilteredLogs(filtered);
|
||||
}, [rawLogs, search, lines, since, typeFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [filteredLogs, autoScroll]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>
|
||||
<span>Number of lines to show</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Number of lines to show (Defaults to 35)"
|
||||
value={lines}
|
||||
onChange={(e) => {
|
||||
setLines(Number(e.target.value) || 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap justify-between items-start sm:items-center gap-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<LineCountFilter value={lines} onValueChange={handleLines} />
|
||||
|
||||
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
|
||||
<div id={id} />
|
||||
<SinceLogsFilter
|
||||
value={since}
|
||||
onValueChange={handleSince}
|
||||
showTimestamp={showTimestamp}
|
||||
onTimestampChange={setShowTimestamp}
|
||||
/>
|
||||
|
||||
<StatusLogsFilter
|
||||
value={typeFilter}
|
||||
setValue={setTypeFilter}
|
||||
title="Log type"
|
||||
options={priorities}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search logs..."
|
||||
value={search}
|
||||
onChange={handleSearch}
|
||||
className="inline-flex h-9 text-sm placeholder-gray-400 w-full sm:w-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 sm:w-auto w-full"
|
||||
onClick={handleDownload}
|
||||
disabled={filteredLogs.length === 0 || !data?.Name}
|
||||
>
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
Download logs
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||
>
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((filteredLog: LogLine, index: number) => (
|
||||
<TerminalLine
|
||||
key={index}
|
||||
log={filteredLog}
|
||||
searchTerm={search}
|
||||
noTimestamp={!showTimestamp}
|
||||
/>
|
||||
))
|
||||
) : isLoading ? (
|
||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||
No logs found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { debounce } from "lodash";
|
||||
import { CheckIcon, Hash } from "lucide-react";
|
||||
import React, { useCallback, useRef } from "react";
|
||||
|
||||
const lineCountOptions = [
|
||||
{ label: "100 lines", value: 100 },
|
||||
{ label: "300 lines", value: 300 },
|
||||
{ label: "500 lines", value: 500 },
|
||||
{ label: "1000 lines", value: 1000 },
|
||||
{ label: "5000 lines", value: 5000 },
|
||||
] as const;
|
||||
|
||||
interface LineCountFilterProps {
|
||||
value: number;
|
||||
onValueChange: (value: number) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function LineCountFilter({
|
||||
value,
|
||||
onValueChange,
|
||||
title = "Limit to",
|
||||
}: LineCountFilterProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const pendingValueRef = useRef<number | null>(null);
|
||||
|
||||
const isPresetValue = lineCountOptions.some(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
|
||||
const debouncedValueChange = useCallback(
|
||||
debounce((numValue: number) => {
|
||||
if (numValue > 0 && numValue !== value) {
|
||||
onValueChange(numValue);
|
||||
pendingValueRef.current = null;
|
||||
}
|
||||
}, 500),
|
||||
[onValueChange, value],
|
||||
);
|
||||
|
||||
const handleInputChange = (input: string) => {
|
||||
setInputValue(input);
|
||||
|
||||
// Extract numbers from input and convert
|
||||
const numValue = Number.parseInt(input.replace(/[^0-9]/g, ""));
|
||||
if (!Number.isNaN(numValue)) {
|
||||
pendingValueRef.current = numValue;
|
||||
debouncedValueChange(numValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (selectedValue: string) => {
|
||||
const preset = lineCountOptions.find((opt) => opt.label === selectedValue);
|
||||
if (preset) {
|
||||
if (preset.value !== value) {
|
||||
onValueChange(preset.value);
|
||||
}
|
||||
setInputValue("");
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const numValue = Number.parseInt(selectedValue);
|
||||
if (
|
||||
!Number.isNaN(numValue) &&
|
||||
numValue > 0 &&
|
||||
numValue !== value &&
|
||||
numValue !== pendingValueRef.current
|
||||
) {
|
||||
onValueChange(numValue);
|
||||
setInputValue("");
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
debouncedValueChange.cancel();
|
||||
};
|
||||
}, [debouncedValueChange]);
|
||||
|
||||
const displayValue = isPresetValue
|
||||
? lineCountOptions.find((option) => option.value === value)?.label
|
||||
: `${value} lines`;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
|
||||
>
|
||||
{title}
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<div className="space-x-1 flex">
|
||||
<Badge variant="blank" className="rounded-sm px-1 font-normal">
|
||||
{displayValue}
|
||||
</Badge>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<CommandPrimitive className="overflow-hidden rounded-md border border-none bg-popover text-popover-foreground">
|
||||
<div className="flex items-center border-b px-3">
|
||||
<Hash className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
placeholder="Number of lines"
|
||||
value={inputValue}
|
||||
onValueChange={handleInputChange}
|
||||
className="flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const numValue = Number.parseInt(
|
||||
inputValue.replace(/[^0-9]/g, ""),
|
||||
);
|
||||
if (
|
||||
!Number.isNaN(numValue) &&
|
||||
numValue > 0 &&
|
||||
numValue !== value &&
|
||||
numValue !== pendingValueRef.current
|
||||
) {
|
||||
handleSelect(inputValue);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CommandPrimitive.List className="max-h-[300px] overflow-y-auto overflow-x-hidden">
|
||||
<CommandPrimitive.Group className="px-2 py-1.5">
|
||||
{lineCountOptions.map((option) => {
|
||||
const isSelected = value === option.value;
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
key={option.value}
|
||||
onSelect={() => handleSelect(option.label)}
|
||||
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 aria-selected:bg-accent aria-selected:text-accent-foreground"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded-sm border border-primary mr-2",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn("h-4 w-4")} />
|
||||
</div>
|
||||
<span>{option.label}</span>
|
||||
</CommandPrimitive.Item>
|
||||
);
|
||||
})}
|
||||
</CommandPrimitive.Group>
|
||||
</CommandPrimitive.List>
|
||||
</CommandPrimitive>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default LineCountFilter;
|
||||
@@ -46,11 +46,7 @@ export const ShowDockerModalLogs = ({
|
||||
<DialogDescription>View the logs for {containerId}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 pt-2.5">
|
||||
<DockerLogsId
|
||||
id="terminal"
|
||||
containerId={containerId || ""}
|
||||
serverId={serverId}
|
||||
/>
|
||||
<DockerLogsId containerId={containerId || ""} serverId={serverId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h";
|
||||
|
||||
const timeRanges: Array<{ label: string; value: TimeFilter }> = [
|
||||
{
|
||||
label: "All time",
|
||||
value: "all",
|
||||
},
|
||||
{
|
||||
label: "Last hour",
|
||||
value: "1h",
|
||||
},
|
||||
{
|
||||
label: "Last 6 hours",
|
||||
value: "6h",
|
||||
},
|
||||
{
|
||||
label: "Last 24 hours",
|
||||
value: "24h",
|
||||
},
|
||||
{
|
||||
label: "Last 7 days",
|
||||
value: "168h",
|
||||
},
|
||||
{
|
||||
label: "Last 30 days",
|
||||
value: "720h",
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface SinceLogsFilterProps {
|
||||
value: TimeFilter;
|
||||
onValueChange: (value: TimeFilter) => void;
|
||||
showTimestamp: boolean;
|
||||
onTimestampChange: (show: boolean) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function SinceLogsFilter({
|
||||
value,
|
||||
onValueChange,
|
||||
showTimestamp,
|
||||
onTimestampChange,
|
||||
title = "Time range",
|
||||
}: SinceLogsFilterProps) {
|
||||
const selectedLabel =
|
||||
timeRanges.find((range) => range.value === value)?.label ??
|
||||
"Select time range";
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
|
||||
>
|
||||
{title}
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<div className="space-x-1 flex">
|
||||
<Badge variant="blank" className="rounded-sm px-1 font-normal">
|
||||
{selectedLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{timeRanges.map((range) => {
|
||||
const isSelected = value === range.value;
|
||||
return (
|
||||
<CommandItem
|
||||
key={range.value}
|
||||
onSelect={() => {
|
||||
if (!isSelected) {
|
||||
onValueChange(range.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn("h-4 w-4")} />
|
||||
</div>
|
||||
<span className="text-sm">{range.label}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
<Separator className="my-2" />
|
||||
<div className="p-2 flex items-center justify-between">
|
||||
<span className="text-sm">Show timestamps</span>
|
||||
<Switch checked={showTimestamp} onCheckedChange={onTimestampChange} />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
|
||||
interface StatusLogsFilterProps {
|
||||
value?: string[];
|
||||
setValue?: (value: string[]) => void;
|
||||
title?: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function StatusLogsFilter({
|
||||
value = [],
|
||||
setValue,
|
||||
title,
|
||||
options,
|
||||
}: StatusLogsFilterProps) {
|
||||
const selectedValues = new Set(value as string[]);
|
||||
const allSelected = selectedValues.size === 0;
|
||||
|
||||
const getSelectedBadges = () => {
|
||||
if (allSelected) {
|
||||
return (
|
||||
<Badge variant="blank" className="rounded-sm px-1 font-normal">
|
||||
All
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedValues.size >= 1) {
|
||||
const selected = options.find((opt) => selectedValues.has(opt.value));
|
||||
return (
|
||||
<>
|
||||
<Badge
|
||||
variant={
|
||||
selected?.value === "success"
|
||||
? "green"
|
||||
: selected?.value === "error"
|
||||
? "red"
|
||||
: selected?.value === "warning"
|
||||
? "orange"
|
||||
: selected?.value === "info"
|
||||
? "blue"
|
||||
: selected?.value === "debug"
|
||||
? "yellow"
|
||||
: "blank"
|
||||
}
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selected?.label}
|
||||
</Badge>
|
||||
{selectedValues.size > 1 && (
|
||||
<Badge variant="blank" className="rounded-sm px-1 font-normal">
|
||||
+{selectedValues.size - 1}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
|
||||
>
|
||||
{title}
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<div className="space-x-1 flex">{getSelectedBadges()}</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setValue?.([]); // Empty array means "All"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
|
||||
allSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn("h-4 w-4")} />
|
||||
</div>
|
||||
<Badge variant="blank">All</Badge>
|
||||
</CommandItem>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
const newValues = new Set(selectedValues);
|
||||
if (isSelected) {
|
||||
newValues.delete(option.value);
|
||||
} else {
|
||||
newValues.add(option.value);
|
||||
}
|
||||
setValue?.(Array.from(newValues));
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible",
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn("h-4 w-4")} />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<Badge
|
||||
variant={
|
||||
option.value === "success"
|
||||
? "green"
|
||||
: option.value === "error"
|
||||
? "red"
|
||||
: option.value === "warning"
|
||||
? "orange"
|
||||
: option.value === "info"
|
||||
? "blue"
|
||||
: option.value === "debug"
|
||||
? "yellow"
|
||||
: "blank"
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
131
apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx
Normal file
131
apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipPortal,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { escapeRegExp } from "lodash";
|
||||
import React from "react";
|
||||
import { type LogLine, getLogType, parseAnsi } from "./utils";
|
||||
|
||||
interface LogLineProps {
|
||||
log: LogLine;
|
||||
noTimestamp?: boolean;
|
||||
searchTerm?: string;
|
||||
}
|
||||
|
||||
export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
const { timestamp, message, rawTimestamp } = log;
|
||||
const { type, variant, color } = getLogType(message);
|
||||
|
||||
const formattedTime = timestamp
|
||||
? timestamp.toLocaleString([], {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
year: "2-digit",
|
||||
second: "2-digit",
|
||||
})
|
||||
: "--- No time found ---";
|
||||
|
||||
const highlightMessage = (text: string, term: string) => {
|
||||
if (!term) {
|
||||
const segments = parseAnsi(text);
|
||||
return segments.map((segment, index) => (
|
||||
<span key={index} className={segment.className || undefined}>
|
||||
{segment.text}
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
// For search, we need to handle both ANSI and search highlighting
|
||||
const segments = parseAnsi(text);
|
||||
return segments.map((segment, index) => {
|
||||
const parts = segment.text.split(
|
||||
new RegExp(`(${escapeRegExp(term)})`, "gi"),
|
||||
);
|
||||
return (
|
||||
<span key={index} className={segment.className || undefined}>
|
||||
{parts.map((part, partIndex) =>
|
||||
part.toLowerCase() === term.toLowerCase() ? (
|
||||
<span
|
||||
key={partIndex}
|
||||
className="bg-yellow-200 dark:bg-yellow-900"
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const tooltip = (color: string, timestamp: string | null) => {
|
||||
const square = (
|
||||
<div className={cn("w-2 h-full flex-shrink-0 rounded-[3px]", color)} />
|
||||
);
|
||||
return timestamp ? (
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{square}</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
sideOffset={5}
|
||||
className="bg-popover border-border z-[99999]"
|
||||
>
|
||||
<p className="text text-xs text-muted-foreground break-all max-w-md">
|
||||
<pre>{timestamp}</pre>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
square
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
|
||||
type === "error"
|
||||
? "bg-red-500/10 hover:bg-red-500/15"
|
||||
: type === "warning"
|
||||
? "bg-yellow-500/10 hover:bg-yellow-500/15"
|
||||
: type === "debug"
|
||||
? "bg-orange-500/10 hover:bg-orange-500/15"
|
||||
: "hover:bg-gray-200/50 dark:hover:bg-gray-800/50",
|
||||
)}
|
||||
>
|
||||
{" "}
|
||||
<div className="flex items-start gap-x-2">
|
||||
{/* Icon to expand the log item maybe implement a colapsible later */}
|
||||
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
|
||||
{tooltip(color, rawTimestamp)}
|
||||
{!noTimestamp && (
|
||||
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 flex-shrink-0">
|
||||
{formattedTime}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Badge
|
||||
variant={variant}
|
||||
className="w-14 justify-center text-[10px] px-1 py-0"
|
||||
>
|
||||
{type}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="dark:text-gray-200 font-mono text-foreground whitespace-pre-wrap break-all">
|
||||
{highlightMessage(message, searchTerm || "")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
apps/dokploy/components/dashboard/docker/logs/utils.ts
Normal file
246
apps/dokploy/components/dashboard/docker/logs/utils.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
export type LogType = "error" | "warning" | "success" | "info" | "debug";
|
||||
export type LogVariant = "red" | "yellow" | "green" | "blue" | "orange";
|
||||
|
||||
export interface LogLine {
|
||||
rawTimestamp: string | null;
|
||||
timestamp: Date | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface LogStyle {
|
||||
type: LogType;
|
||||
variant: LogVariant;
|
||||
color: string;
|
||||
}
|
||||
interface AnsiSegment {
|
||||
text: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
const ansiToTailwind: Record<number, string> = {
|
||||
// Reset
|
||||
0: "",
|
||||
// Regular colors
|
||||
30: "text-black dark:text-gray-900",
|
||||
31: "text-red-600 dark:text-red-500",
|
||||
32: "text-green-600 dark:text-green-500",
|
||||
33: "text-yellow-600 dark:text-yellow-500",
|
||||
34: "text-blue-600 dark:text-blue-500",
|
||||
35: "text-purple-600 dark:text-purple-500",
|
||||
36: "text-cyan-600 dark:text-cyan-500",
|
||||
37: "text-gray-600 dark:text-gray-400",
|
||||
// Bright colors
|
||||
90: "text-gray-500 dark:text-gray-600",
|
||||
91: "text-red-500 dark:text-red-600",
|
||||
92: "text-green-500 dark:text-green-600",
|
||||
93: "text-yellow-500 dark:text-yellow-600",
|
||||
94: "text-blue-500 dark:text-blue-600",
|
||||
95: "text-purple-500 dark:text-purple-600",
|
||||
96: "text-cyan-500 dark:text-cyan-600",
|
||||
97: "text-white dark:text-gray-300",
|
||||
// Background colors
|
||||
40: "bg-black",
|
||||
41: "bg-red-600",
|
||||
42: "bg-green-600",
|
||||
43: "bg-yellow-600",
|
||||
44: "bg-blue-600",
|
||||
45: "bg-purple-600",
|
||||
46: "bg-cyan-600",
|
||||
47: "bg-white",
|
||||
// Formatting
|
||||
1: "font-bold",
|
||||
2: "opacity-75",
|
||||
3: "italic",
|
||||
4: "underline",
|
||||
};
|
||||
|
||||
const LOG_STYLES: Record<LogType, LogStyle> = {
|
||||
error: {
|
||||
type: "error",
|
||||
variant: "red",
|
||||
color: "bg-red-500/40",
|
||||
},
|
||||
warning: {
|
||||
type: "warning",
|
||||
variant: "orange",
|
||||
color: "bg-orange-500/40",
|
||||
},
|
||||
debug: {
|
||||
type: "debug",
|
||||
variant: "yellow",
|
||||
color: "bg-yellow-500/40",
|
||||
},
|
||||
success: {
|
||||
type: "success",
|
||||
variant: "green",
|
||||
color: "bg-green-500/40",
|
||||
},
|
||||
info: {
|
||||
type: "info",
|
||||
variant: "blue",
|
||||
color: "bg-blue-600/40",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function parseLogs(logString: string): LogLine[] {
|
||||
// Regex to match the log line format
|
||||
// Exemple of return :
|
||||
// 1 2024-12-10T10:00:00.000Z The server is running on port 8080
|
||||
// Should return :
|
||||
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
||||
// message: "The server is running on port 8080" }
|
||||
const logRegex =
|
||||
/^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/;
|
||||
|
||||
return logString
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== "")
|
||||
.map((line) => {
|
||||
const match = line.match(logRegex);
|
||||
if (!match) return null;
|
||||
|
||||
const [, , timestamp, message] = match;
|
||||
|
||||
if (!message?.trim()) return null;
|
||||
|
||||
// Delete other timestamps and keep only the one from --timestamps
|
||||
const cleanedMessage = message
|
||||
?.replace(
|
||||
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC/g,
|
||||
"",
|
||||
)
|
||||
.trim();
|
||||
|
||||
return {
|
||||
rawTimestamp: timestamp ?? null,
|
||||
timestamp: timestamp ? new Date(timestamp.replace(" UTC", "Z")) : null,
|
||||
message: cleanedMessage,
|
||||
};
|
||||
})
|
||||
.filter((log) => log !== null);
|
||||
}
|
||||
|
||||
// Detect log type based on message content
|
||||
export const getLogType = (message: string): LogStyle => {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
if (
|
||||
/(?:^|\s)(?:info|inf|information):?\s/i.test(lowerMessage) ||
|
||||
/\[(?:info|information)\]/i.test(lowerMessage) ||
|
||||
/\b(?:status|state|current|progress)\b:?\s/i.test(lowerMessage) ||
|
||||
/\b(?:processing|executing|performing)\b/i.test(lowerMessage)
|
||||
) {
|
||||
return LOG_STYLES.info;
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) ||
|
||||
/\b(?:exception|failed|failure)\b/i.test(lowerMessage) ||
|
||||
/(?:stack\s?trace):\s*$/i.test(lowerMessage) ||
|
||||
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) ||
|
||||
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) ||
|
||||
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) ||
|
||||
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) ||
|
||||
/\[(?:error|err|fatal)\]/i.test(lowerMessage) ||
|
||||
/\b(?:crash|critical|fatal)\b/i.test(lowerMessage) ||
|
||||
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage)
|
||||
) {
|
||||
return LOG_STYLES.error;
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:^|\s)(?:warning|warn):?\s/i.test(lowerMessage) ||
|
||||
/\[(?:warn(?:ing)?|attention)\]/i.test(lowerMessage) ||
|
||||
/(?:deprecated|obsolete)\s+(?:since|in|as\s+of)/i.test(lowerMessage) ||
|
||||
/\b(?:caution|attention|notice):\s/i.test(lowerMessage) ||
|
||||
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
|
||||
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
|
||||
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
|
||||
/\b(?:unstable|experimental)\b/i.test(lowerMessage)
|
||||
) {
|
||||
return LOG_STYLES.warning;
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:successfully|complete[d]?)\s+(?:initialized|started|completed|created|done|deployed)/i.test(
|
||||
lowerMessage,
|
||||
) ||
|
||||
/\[(?:success|ok|done)\]/i.test(lowerMessage) ||
|
||||
/(?:listening|running)\s+(?:on|at)\s+(?:port\s+)?\d+/i.test(lowerMessage) ||
|
||||
/(?:connected|established|ready)\s+(?:to|for|on)/i.test(lowerMessage) ||
|
||||
/\b(?:loaded|mounted|initialized)\s+successfully\b/i.test(lowerMessage) ||
|
||||
/✓|√|✅|\[ok\]|done!/i.test(lowerMessage) ||
|
||||
/\b(?:success(?:ful)?|completed|ready)\b/i.test(lowerMessage) ||
|
||||
/\b(?:started|starting|active)\b/i.test(lowerMessage)
|
||||
) {
|
||||
return LOG_STYLES.success;
|
||||
}
|
||||
|
||||
if (
|
||||
/(?:^|\s)(?:info|inf):?\s/i.test(lowerMessage) ||
|
||||
/\[(info|log|debug|trace|server|db|api|http|request|response)\]/i.test(
|
||||
lowerMessage,
|
||||
) ||
|
||||
/\b(?:version|config|import|load|get|HTTP|PATCH|POST|debug)\b:?/i.test(
|
||||
lowerMessage,
|
||||
)
|
||||
) {
|
||||
return LOG_STYLES.debug;
|
||||
}
|
||||
|
||||
return LOG_STYLES.info;
|
||||
};
|
||||
|
||||
export function parseAnsi(text: string) {
|
||||
const segments: { text: string; className: string }[] = [];
|
||||
let currentIndex = 0;
|
||||
let currentClasses: string[] = [];
|
||||
|
||||
while (currentIndex < text.length) {
|
||||
const escStart = text.indexOf("\x1b[", currentIndex);
|
||||
|
||||
// No more escape sequences found
|
||||
if (escStart === -1) {
|
||||
if (currentIndex < text.length) {
|
||||
segments.push({
|
||||
text: text.slice(currentIndex),
|
||||
className: currentClasses.join(" "),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Add text before escape sequence
|
||||
if (escStart > currentIndex) {
|
||||
segments.push({
|
||||
text: text.slice(currentIndex, escStart),
|
||||
className: currentClasses.join(" "),
|
||||
});
|
||||
}
|
||||
|
||||
const escEnd = text.indexOf("m", escStart);
|
||||
if (escEnd === -1) break;
|
||||
|
||||
// Handle multiple codes in one sequence (e.g., \x1b[1;31m)
|
||||
const codesStr = text.slice(escStart + 2, escEnd);
|
||||
const codes = codesStr.split(";").map((c) => Number.parseInt(c, 10));
|
||||
|
||||
if (codes.includes(0)) {
|
||||
// Reset all formatting
|
||||
currentClasses = [];
|
||||
} else {
|
||||
// Add new classes for each code
|
||||
for (const code of codes) {
|
||||
const className = ansiToTailwind[code];
|
||||
if (className && !currentClasses.includes(className)) {
|
||||
currentClasses.push(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentIndex = escEnd + 1;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
|
||||
const Terminal = dynamic(
|
||||
() => import("./docker-terminal").then((e) => e.DockerTerminal),
|
||||
@@ -27,8 +30,27 @@ export const DockerTerminalModal = ({
|
||||
containerId,
|
||||
serverId,
|
||||
}: Props) => {
|
||||
const [mainDialogOpen, setMainDialogOpen] = useState(false);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
|
||||
const handleMainDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setConfirmDialogOpen(true);
|
||||
} else {
|
||||
setMainDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setConfirmDialogOpen(false);
|
||||
setMainDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setConfirmDialogOpen(false);
|
||||
};
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
@@ -50,6 +72,24 @@ export const DockerTerminalModal = ({
|
||||
containerId={containerId}
|
||||
serverId={serverId || ""}
|
||||
/>
|
||||
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Are you sure you want to close the terminal?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
By clicking the confirm button, the terminal will be closed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>Confirm</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FitAddon } from "xterm-addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { AttachAddon } from "@xterm/addon-attach";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -18,6 +19,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
}) => {
|
||||
const termRef = useRef(null);
|
||||
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
||||
const { resolvedTheme } = useTheme();
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(id);
|
||||
if (container) {
|
||||
@@ -25,13 +27,12 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
}
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
lineHeight: 1.4,
|
||||
convertEol: true,
|
||||
theme: {
|
||||
cursor: "transparent",
|
||||
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
|
||||
background: "rgba(0, 0, 0, 0)",
|
||||
foreground: "currentColor",
|
||||
},
|
||||
});
|
||||
const addonFit = new FitAddon();
|
||||
@@ -45,6 +46,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
const addonAttach = new AttachAddon(ws);
|
||||
// @ts-ignore
|
||||
term.open(termRef.current);
|
||||
// @ts-ignore
|
||||
term.loadAddon(addonFit);
|
||||
term.loadAddon(addonAttach);
|
||||
addonFit.fit();
|
||||
@@ -66,7 +68,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
|
||||
<div className="w-full h-full rounded-lg p-2 bg-transparent border">
|
||||
<div id={id} ref={termRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { Copy, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -99,9 +100,26 @@ export const DeleteMariadb = ({ mariadbId }: Props) => {
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<span>
|
||||
To confirm, type{" "}
|
||||
<Badge
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (data?.name && data?.appName) {
|
||||
navigator.clipboard.writeText(
|
||||
`${data.name}/${data.appName}`,
|
||||
);
|
||||
toast.success("Copied to clipboard. Be careful!");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{data?.name}/{data?.appName}
|
||||
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||
</Badge>{" "}
|
||||
in the box below:
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { Copy, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -98,9 +99,26 @@ export const DeleteMongo = ({ mongoId }: Props) => {
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<span>
|
||||
To confirm, type{" "}
|
||||
<Badge
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (data?.name && data?.appName) {
|
||||
navigator.clipboard.writeText(
|
||||
`${data.name}/${data.appName}`,
|
||||
);
|
||||
toast.success("Copied to clipboard. Be careful!");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{data?.name}/{data?.appName}
|
||||
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||
</Badge>{" "}
|
||||
in the box below:
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { Copy, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -97,9 +98,26 @@ export const DeleteMysql = ({ mysqlId }: Props) => {
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<span>
|
||||
To confirm, type{" "}
|
||||
<Badge
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (data?.name && data?.appName) {
|
||||
navigator.clipboard.writeText(
|
||||
`${data.name}/${data.appName}`,
|
||||
);
|
||||
toast.success("Copied to clipboard. Be careful!");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{data?.name}/{data?.appName}
|
||||
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||
</Badge>{" "}
|
||||
in the box below:
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { Copy, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -100,9 +101,26 @@ export const DeletePostgres = ({ postgresId }: Props) => {
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<span>
|
||||
To confirm, type{" "}
|
||||
<Badge
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (data?.name && data?.appName) {
|
||||
navigator.clipboard.writeText(
|
||||
`${data.name}/${data.appName}`,
|
||||
);
|
||||
toast.success("Copied to clipboard. Be careful!");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{data?.name}/{data?.appName}
|
||||
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||
</Badge>{" "}
|
||||
in the box below:
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
||||
@@ -92,7 +92,8 @@ export const AddTemplate = ({ projectId }: Props) => {
|
||||
template.tags.some((tag) => selectedTags.includes(tag));
|
||||
const matchesQuery =
|
||||
query === "" ||
|
||||
template.name.toLowerCase().includes(query.toLowerCase());
|
||||
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
template.description.toLowerCase().includes(query.toLowerCase());
|
||||
return matchesTags && matchesQuery;
|
||||
}) || [];
|
||||
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookIcon,
|
||||
ExternalLink,
|
||||
ExternalLinkIcon,
|
||||
FolderInput,
|
||||
MoreHorizontalIcon,
|
||||
TrashIcon,
|
||||
AlertTriangle,
|
||||
BookIcon,
|
||||
ExternalLink,
|
||||
ExternalLinkIcon,
|
||||
FolderInput,
|
||||
MoreHorizontalIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
@@ -38,253 +38,257 @@ import { ProjectEnviroment } from "./project-enviroment";
|
||||
import { UpdateProject } from "./update";
|
||||
|
||||
export const ShowProjects = () => {
|
||||
const utils = api.useUtils();
|
||||
const { data } = api.project.all.useQuery();
|
||||
const { data: auth } = api.auth.get.useQuery();
|
||||
const { data: user } = api.user.byAuthId.useQuery(
|
||||
{
|
||||
authId: auth?.id || "",
|
||||
},
|
||||
{
|
||||
enabled: !!auth?.id && auth?.rol === "user",
|
||||
},
|
||||
);
|
||||
const { mutateAsync } = api.project.remove.useMutation();
|
||||
const utils = api.useUtils();
|
||||
const { data } = api.project.all.useQuery();
|
||||
const { data: auth } = api.auth.get.useQuery();
|
||||
const { data: user } = api.user.byAuthId.useQuery(
|
||||
{
|
||||
authId: auth?.id || "",
|
||||
},
|
||||
{
|
||||
enabled: !!auth?.id && auth?.rol === "user",
|
||||
}
|
||||
);
|
||||
const { mutateAsync } = api.project.remove.useMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{data?.length === 0 && (
|
||||
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
|
||||
<FolderInput className="size-10 md:size-28 text-muted-foreground" />
|
||||
<span className="text-center font-medium text-muted-foreground">
|
||||
No projects added yet. Click on Create project.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
|
||||
{data?.map((project) => {
|
||||
const emptyServices =
|
||||
project?.mariadb.length === 0 &&
|
||||
project?.mongo.length === 0 &&
|
||||
project?.mysql.length === 0 &&
|
||||
project?.postgres.length === 0 &&
|
||||
project?.redis.length === 0 &&
|
||||
project?.applications.length === 0 &&
|
||||
project?.compose.length === 0;
|
||||
return (
|
||||
<>
|
||||
{data?.length === 0 && (
|
||||
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
|
||||
<FolderInput className="size-10 md:size-28 text-muted-foreground" />
|
||||
<span className="text-center font-medium text-muted-foreground">
|
||||
No projects added yet. Click on Create project.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
|
||||
{data?.map((project) => {
|
||||
const emptyServices =
|
||||
project?.mariadb.length === 0 &&
|
||||
project?.mongo.length === 0 &&
|
||||
project?.mysql.length === 0 &&
|
||||
project?.postgres.length === 0 &&
|
||||
project?.redis.length === 0 &&
|
||||
project?.applications.length === 0 &&
|
||||
project?.compose.length === 0;
|
||||
|
||||
const totalServices =
|
||||
project?.mariadb.length +
|
||||
project?.mongo.length +
|
||||
project?.mysql.length +
|
||||
project?.postgres.length +
|
||||
project?.redis.length +
|
||||
project?.applications.length +
|
||||
project?.compose.length;
|
||||
const totalServices =
|
||||
project?.mariadb.length +
|
||||
project?.mongo.length +
|
||||
project?.mysql.length +
|
||||
project?.postgres.length +
|
||||
project?.redis.length +
|
||||
project?.applications.length +
|
||||
project?.compose.length;
|
||||
|
||||
const flattedDomains = [
|
||||
...project.applications.flatMap((a) => a.domains),
|
||||
...project.compose.flatMap((a) => a.domains),
|
||||
];
|
||||
const flattedDomains = [
|
||||
...project.applications.flatMap((a) => a.domains),
|
||||
...project.compose.flatMap((a) => a.domains),
|
||||
];
|
||||
|
||||
const renderDomainsDropdown = (
|
||||
item: typeof project.compose | typeof project.applications,
|
||||
) =>
|
||||
item[0] ? (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{"applicationId" in item[0] ? "Applications" : "Compose"}
|
||||
</DropdownMenuLabel>
|
||||
{item.map((a) => (
|
||||
<Fragment
|
||||
key={"applicationId" in a ? a.applicationId : a.composeId}
|
||||
>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-normal capitalize text-xs ">
|
||||
{a.name}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{a.domains.map((domain) => (
|
||||
<DropdownMenuItem key={domain.domainId} asChild>
|
||||
<Link
|
||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
>
|
||||
<span>{domain.host}</span>
|
||||
<ExternalLink className="size-4 shrink-0" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
) : null;
|
||||
const renderDomainsDropdown = (
|
||||
item: typeof project.compose | typeof project.applications
|
||||
) =>
|
||||
item[0] ? (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>
|
||||
{"applicationId" in item[0] ? "Applications" : "Compose"}
|
||||
</DropdownMenuLabel>
|
||||
{item.map((a) => (
|
||||
<Fragment
|
||||
key={"applicationId" in a ? a.applicationId : a.composeId}
|
||||
>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-normal capitalize text-xs ">
|
||||
{a.name}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{a.domains.map((domain) => (
|
||||
<DropdownMenuItem key={domain.domainId} asChild>
|
||||
<Link
|
||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${
|
||||
domain.host
|
||||
}${domain.path}`}
|
||||
>
|
||||
<span>{domain.host}</span>
|
||||
<ExternalLink className="size-4 shrink-0" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</Fragment>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div key={project.projectId} className="w-full lg:max-w-md">
|
||||
<Link href={`/dashboard/project/${project.projectId}`}>
|
||||
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
|
||||
{flattedDomains.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
|
||||
size="sm"
|
||||
variant="default"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[200px] space-y-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{renderDomainsDropdown(project.applications)}
|
||||
{renderDomainsDropdown(project.compose)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : flattedDomains[0] ? (
|
||||
<Button
|
||||
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Link
|
||||
href={`${flattedDomains[0].https ? "https" : "http"}://${flattedDomains[0].host}${flattedDomains[0].path}`}
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
return (
|
||||
<div key={project.projectId} className="w-full lg:max-w-md">
|
||||
<Link href={`/dashboard/project/${project.projectId}`}>
|
||||
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
|
||||
{flattedDomains.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
|
||||
size="sm"
|
||||
variant="default"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[200px] space-y-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{renderDomainsDropdown(project.applications)}
|
||||
{renderDomainsDropdown(project.compose)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : flattedDomains[0] ? (
|
||||
<Button
|
||||
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Link
|
||||
href={`${
|
||||
flattedDomains[0].https ? "https" : "http"
|
||||
}://${flattedDomains[0].host}${flattedDomains[0].path}`}
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<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">
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<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">
|
||||
{project.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{project.description}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex self-start space-x-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="px-2"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[200px] space-y-2">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ProjectEnviroment
|
||||
projectId={project.projectId}
|
||||
/>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<UpdateProject projectId={project.projectId} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{project.description}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex self-start space-x-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="px-2"
|
||||
>
|
||||
<MoreHorizontalIcon className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[200px] space-y-2">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
Actions
|
||||
</DropdownMenuLabel>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ProjectEnviroment
|
||||
projectId={project.projectId}
|
||||
/>
|
||||
</div>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<UpdateProject projectId={project.projectId} />
|
||||
</div>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{(auth?.rol === "admin" ||
|
||||
user?.canDeleteProjects) && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger className="w-full">
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure to delete this project?
|
||||
</AlertDialogTitle>
|
||||
{!emptyServices ? (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
|
||||
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
You have active services, please
|
||||
delete them first
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone
|
||||
</AlertDialogDescription>
|
||||
)}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={!emptyServices}
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
projectId: project.projectId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Project delete succesfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error to delete this project",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
utils.project.all.invalidate();
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="pt-4">
|
||||
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
|
||||
<DateTooltip date={project.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
<span>
|
||||
{totalServices}{" "}
|
||||
{totalServices === 1 ? "service" : "services"}
|
||||
</span>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
{(auth?.rol === "admin" ||
|
||||
user?.canDeleteProjects) && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger className="w-full">
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure to delete this project?
|
||||
</AlertDialogTitle>
|
||||
{!emptyServices ? (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
|
||||
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
|
||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
You have active services, please
|
||||
delete them first
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone
|
||||
</AlertDialogDescription>
|
||||
)}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={!emptyServices}
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
projectId: project.projectId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Project delete succesfully"
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error to delete this project"
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
utils.project.all.invalidate();
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="pt-4">
|
||||
<div className="space-y-1 text-sm flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
|
||||
<DateTooltip date={project.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
<span>
|
||||
{totalServices}{" "}
|
||||
{totalServices === 1 ? "service" : "services"}
|
||||
</span>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { Copy, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -97,9 +98,26 @@ export const DeleteRedis = ({ redisId }: Props) => {
|
||||
name="projectName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
||||
below
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<span>
|
||||
To confirm, type{" "}
|
||||
<Badge
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (data?.name && data?.appName) {
|
||||
navigator.clipboard.writeText(
|
||||
`${data.name}/${data.appName}`,
|
||||
);
|
||||
toast.success("Copied to clipboard. Be careful!");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{data?.name}/{data?.appName}
|
||||
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||
</Badge>{" "}
|
||||
in the box below:
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
||||
@@ -49,8 +49,11 @@ export const columns: ColumnDef<LogEntry>[] = [
|
||||
const log = row.original;
|
||||
return (
|
||||
<div className=" flex flex-col gap-2">
|
||||
<div className="flex flex-row gap-3 ">
|
||||
<div className="flex items-center flex-row gap-3 ">
|
||||
{log.RequestMethod}{" "}
|
||||
<div className="inline-flex items-center gap-2 bg-muted p-1 rounded">
|
||||
<span>{log.RequestAddr}</span>
|
||||
</div>
|
||||
{log.RequestPath.length > 100
|
||||
? `${log.RequestPath.slice(0, 82)}...`
|
||||
: log.RequestPath}
|
||||
|
||||
189
apps/dokploy/components/dashboard/search-command.tsx
Normal file
189
apps/dokploy/components/dashboard/search-command.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandList,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandDialog,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
extractServices,
|
||||
type Services,
|
||||
} from "@/pages/dashboard/project/[projectId]";
|
||||
import type { findProjectById } from "@dokploy/server/services/project";
|
||||
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
|
||||
import {
|
||||
MariadbIcon,
|
||||
MongodbIcon,
|
||||
MysqlIcon,
|
||||
PostgresqlIcon,
|
||||
RedisIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
import { api } from "@/utils/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusTooltip } from "../shared/status-tooltip";
|
||||
|
||||
type Project = Awaited<ReturnType<typeof findProjectById>>;
|
||||
|
||||
export const SearchCommand = () => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
|
||||
const { data } = api.project.all.useQuery();
|
||||
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((open) => !open);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", down);
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput
|
||||
placeholder={"Search projects or settings"}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No projects added yet. Click on Create project.
|
||||
</CommandEmpty>
|
||||
<CommandGroup heading={"Projects"}>
|
||||
<CommandList>
|
||||
{data?.map((project) => (
|
||||
<CommandItem
|
||||
key={project.projectId}
|
||||
onSelect={() => {
|
||||
router.push(`/dashboard/project/${project.projectId}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
||||
{project.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={"Services"}>
|
||||
<CommandList>
|
||||
{data?.map((project) => {
|
||||
const applications: Services[] = extractServices(project);
|
||||
return applications.map((application) => (
|
||||
<CommandItem
|
||||
key={application.id}
|
||||
onSelect={() => {
|
||||
router.push(
|
||||
`/dashboard/project/${project.projectId}/services/${application.type}/${application.id}`
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{application.type === "postgres" && (
|
||||
<PostgresqlIcon className="h-6 w-6 mr-2" />
|
||||
)}
|
||||
{application.type === "redis" && (
|
||||
<RedisIcon className="h-6 w-6 mr-2" />
|
||||
)}
|
||||
{application.type === "mariadb" && (
|
||||
<MariadbIcon className="h-6 w-6 mr-2" />
|
||||
)}
|
||||
{application.type === "mongo" && (
|
||||
<MongodbIcon className="h-6 w-6 mr-2" />
|
||||
)}
|
||||
{application.type === "mysql" && (
|
||||
<MysqlIcon className="h-6 w-6 mr-2" />
|
||||
)}
|
||||
{application.type === "application" && (
|
||||
<GlobeIcon className="h-6 w-6 mr-2" />
|
||||
)}
|
||||
{application.type === "compose" && (
|
||||
<CircuitBoard className="h-6 w-6 mr-2" />
|
||||
)}
|
||||
<span className="flex-grow">
|
||||
{project.name} / {application.name}{" "}
|
||||
<div style={{ display: "none" }}>{application.id}</div>
|
||||
</span>
|
||||
<div>
|
||||
<StatusTooltip status={application.status} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
));
|
||||
})}
|
||||
</CommandList>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={"Application"} hidden={true}>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/projects");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Projects
|
||||
</CommandItem>
|
||||
{!isCloud && (
|
||||
<>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/monitoring");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Monitoring
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/traefik");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Traefik
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/docker");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Docker
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/requests");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Requests
|
||||
</CommandItem>
|
||||
</>
|
||||
)}
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/settings/server");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Languages } from "@/lib/languages";
|
||||
import useLocale from "@/utils/hooks/use-locale";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useTheme } from "next-themes";
|
||||
@@ -37,12 +38,9 @@ const appearanceFormSchema = z.object({
|
||||
theme: z.enum(["light", "dark", "system"], {
|
||||
required_error: "Please select a theme.",
|
||||
}),
|
||||
language: z.enum(
|
||||
["en", "pl", "ru", "fr", "de", "tr", "zh-Hant", "zh-Hans", "fa"],
|
||||
{
|
||||
required_error: "Please select a language.",
|
||||
},
|
||||
),
|
||||
language: z.nativeEnum(Languages, {
|
||||
required_error: "Please select a language.",
|
||||
}),
|
||||
});
|
||||
|
||||
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
|
||||
@@ -50,7 +48,7 @@ type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
|
||||
// This can come from your database or API.
|
||||
const defaultValues: Partial<AppearanceFormValues> = {
|
||||
theme: "system",
|
||||
language: "en",
|
||||
language: Languages.English,
|
||||
};
|
||||
|
||||
export function AppearanceForm() {
|
||||
@@ -175,24 +173,15 @@ export function AppearanceForm() {
|
||||
<SelectValue placeholder="No preset selected" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "Polski", value: "pl" },
|
||||
{ label: "Русский", value: "ru" },
|
||||
{ label: "Français", value: "fr" },
|
||||
{ label: "Deutsch", value: "de" },
|
||||
{ label: "繁體中文", value: "zh-Hant" },
|
||||
{ label: "简体中文", value: "zh-Hans" },
|
||||
{ label: "Türkçe", value: "tr" },
|
||||
{
|
||||
label: "Persian",
|
||||
value: "fa",
|
||||
},
|
||||
].map((preset) => (
|
||||
<SelectItem key={preset.label} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{Object.keys(Languages).map((preset) => {
|
||||
const value =
|
||||
Languages[preset as keyof typeof Languages];
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
{preset}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
|
||||
@@ -6,13 +6,144 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { AlertCircle, Link, ShieldCheck } from "lucide-react";
|
||||
import { AddCertificate } from "./add-certificate";
|
||||
import { DeleteCertificate } from "./delete-certificate";
|
||||
|
||||
export const ShowCertificates = () => {
|
||||
const { data } = api.certificates.all.useQuery();
|
||||
|
||||
const extractExpirationDate = (certData: string): Date | null => {
|
||||
try {
|
||||
const match = certData.match(
|
||||
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/,
|
||||
);
|
||||
if (!match?.[1]) return null;
|
||||
|
||||
const base64Cert = match[1].replace(/\s/g, "");
|
||||
const binaryStr = window.atob(base64Cert);
|
||||
const bytes = new Uint8Array(binaryStr.length);
|
||||
|
||||
for (let i = 0; i < binaryStr.length; i++) {
|
||||
bytes[i] = binaryStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
let dateFound = 0;
|
||||
for (let i = 0; i < bytes.length - 2; i++) {
|
||||
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
|
||||
const dateType = bytes[i];
|
||||
const dateLength = bytes[i + 1];
|
||||
if (typeof dateLength === "undefined") continue;
|
||||
|
||||
if (dateFound === 0) {
|
||||
dateFound++;
|
||||
i += dateLength + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let dateStr = "";
|
||||
for (let j = 0; j < dateLength; j++) {
|
||||
const charCode = bytes[i + 2 + j];
|
||||
if (typeof charCode === "undefined") continue;
|
||||
dateStr += String.fromCharCode(charCode);
|
||||
}
|
||||
|
||||
if (dateType === 0x17) {
|
||||
// UTCTime (YYMMDDhhmmssZ)
|
||||
const year = Number.parseInt(dateStr.slice(0, 2));
|
||||
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
fullYear,
|
||||
Number.parseInt(dateStr.slice(2, 4)) - 1,
|
||||
Number.parseInt(dateStr.slice(4, 6)),
|
||||
Number.parseInt(dateStr.slice(6, 8)),
|
||||
Number.parseInt(dateStr.slice(8, 10)),
|
||||
Number.parseInt(dateStr.slice(10, 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// GeneralizedTime (YYYYMMDDhhmmssZ)
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
Number.parseInt(dateStr.slice(0, 4)),
|
||||
Number.parseInt(dateStr.slice(4, 6)) - 1,
|
||||
Number.parseInt(dateStr.slice(6, 8)),
|
||||
Number.parseInt(dateStr.slice(8, 10)),
|
||||
Number.parseInt(dateStr.slice(10, 12)),
|
||||
Number.parseInt(dateStr.slice(12, 14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error parsing certificate:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getExpirationStatus = (certData: string) => {
|
||||
const expirationDate = extractExpirationDate(certData);
|
||||
|
||||
if (!expirationDate)
|
||||
return {
|
||||
status: "unknown" as const,
|
||||
className: "text-muted-foreground",
|
||||
message: "Could not determine expiration",
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const daysUntilExpiration = Math.ceil(
|
||||
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
if (daysUntilExpiration < 0) {
|
||||
return {
|
||||
status: "expired" as const,
|
||||
className: "text-red-500",
|
||||
message: `Expired on ${expirationDate.toLocaleDateString([], {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (daysUntilExpiration <= 30) {
|
||||
return {
|
||||
status: "warning" as const,
|
||||
className: "text-yellow-500",
|
||||
message: `Expires in ${daysUntilExpiration} days`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "valid" as const,
|
||||
className: "text-muted-foreground",
|
||||
message: `Expires ${expirationDate.toLocaleDateString([], {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}`,
|
||||
};
|
||||
};
|
||||
|
||||
const getCertificateChainInfo = (certData: string) => {
|
||||
const certCount = (certData.match(/-----BEGIN CERTIFICATE-----/g) || [])
|
||||
.length;
|
||||
return certCount > 1
|
||||
? {
|
||||
isChain: true,
|
||||
count: certCount,
|
||||
}
|
||||
: {
|
||||
isChain: false,
|
||||
count: 1,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Card className="bg-transparent h-full">
|
||||
@@ -23,7 +154,7 @@ export const ShowCertificates = () => {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-4 h-full">
|
||||
{data?.length === 0 ? (
|
||||
{!data?.length ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<ShieldCheck className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
@@ -35,21 +166,53 @@ export const ShowCertificates = () => {
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{data?.map((destination, index) => (
|
||||
<div
|
||||
key={destination.certificateId}
|
||||
className="flex items-center justify-between border p-4 rounded-lg"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{index + 1}. {destination.name}
|
||||
</span>
|
||||
<div className="flex flex-row gap-3">
|
||||
<DeleteCertificate
|
||||
certificateId={destination.certificateId}
|
||||
/>
|
||||
{data.map((certificate, index) => {
|
||||
const expiration = getExpirationStatus(
|
||||
certificate.certificateData,
|
||||
);
|
||||
const chainInfo = getCertificateChainInfo(
|
||||
certificate.certificateData,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={certificate.certificateId}
|
||||
className="flex flex-col border p-4 rounded-lg space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{index + 1}. {certificate.name}
|
||||
</span>
|
||||
{chainInfo.isChain && (
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
|
||||
<Link className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Chain ({chainInfo.count})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DeleteCertificate
|
||||
certificateId={certificate.certificateId}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs flex items-center gap-1.5 ${expiration.className}`}
|
||||
>
|
||||
{expiration.status !== "valid" && (
|
||||
<AlertCircle className="size-3" />
|
||||
)}
|
||||
{expiration.message}
|
||||
{certificate.autoRenew &&
|
||||
expiration.status !== "valid" && (
|
||||
<span className="text-xs text-emerald-500 ml-1">
|
||||
(Auto-renewal enabled)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<AddCertificate />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -33,7 +34,13 @@ export const ShowNodeData = ({ data }: Props) => {
|
||||
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card">
|
||||
<code>
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
{JSON.stringify(data, null, 2)}
|
||||
<CodeEditor
|
||||
language="json"
|
||||
lineWrapping
|
||||
lineNumbers={false}
|
||||
readOnly
|
||||
value={JSON.stringify(data, null, 2)}
|
||||
/>
|
||||
</pre>
|
||||
</code>
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,24 @@ export const AddGithubProvider = () => {
|
||||
/>
|
||||
<br />
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<a
|
||||
href={
|
||||
isOrganization && organizationName
|
||||
? `https://github.com/organizations/${organizationName}/settings/installations`
|
||||
: "https://github.com/settings/installations"
|
||||
}
|
||||
className={`text-muted-foreground text-sm hover:underline duration-300
|
||||
${
|
||||
isOrganization && !organizationName
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Unsure if you already have an app?
|
||||
</a>
|
||||
<Button
|
||||
disabled={isOrganization && organizationName.length < 1}
|
||||
type="submit"
|
||||
|
||||
@@ -33,6 +33,9 @@ const Schema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
gitlabUrl: z.string().min(1, {
|
||||
message: "GitLab URL is required",
|
||||
}),
|
||||
applicationId: z.string().min(1, {
|
||||
message: "Application ID is required",
|
||||
}),
|
||||
@@ -62,16 +65,22 @@ export const AddGitlabProvider = () => {
|
||||
applicationSecret: "",
|
||||
groupName: "",
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
|
||||
const gitlabUrl = form.watch("gitlabUrl");
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
applicationId: "",
|
||||
applicationSecret: "",
|
||||
groupName: "",
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
});
|
||||
}, [form, isOpen]);
|
||||
|
||||
@@ -83,6 +92,7 @@ export const AddGitlabProvider = () => {
|
||||
authId: auth?.id || "",
|
||||
name: data.name || "",
|
||||
redirectUri: data.redirectUri || "",
|
||||
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
@@ -129,7 +139,7 @@ export const AddGitlabProvider = () => {
|
||||
<li className="flex flex-row gap-2 items-center">
|
||||
Go to your GitLab profile settings{" "}
|
||||
<Link
|
||||
href="https://gitlab.com/-/profile/applications"
|
||||
href={`${gitlabUrl}/-/profile/applications`}
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="w-fit text-primary size-4" />
|
||||
@@ -169,6 +179,20 @@ export const AddGitlabProvider = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gitlabUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gitlab URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://gitlab.com/" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUri"
|
||||
|
||||
@@ -30,6 +30,9 @@ const Schema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
gitlabUrl: z.string().url({
|
||||
message: "Invalid Gitlab URL",
|
||||
}),
|
||||
groupName: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -40,7 +43,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
const { data: gitlab } = api.gitlab.one.useQuery(
|
||||
const { data: gitlab, refetch } = api.gitlab.one.useQuery(
|
||||
{
|
||||
gitlabId,
|
||||
},
|
||||
@@ -57,6 +60,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
defaultValues: {
|
||||
groupName: "",
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -67,6 +71,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
form.reset({
|
||||
groupName: gitlab?.groupName || "",
|
||||
name: gitlab?.gitProvider.name || "",
|
||||
gitlabUrl: gitlab?.gitlabUrl || "",
|
||||
});
|
||||
}, [form, isOpen]);
|
||||
|
||||
@@ -76,11 +81,13 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
gitProviderId: gitlab?.gitProviderId || "",
|
||||
groupName: data.groupName || "",
|
||||
name: data.name || "",
|
||||
gitlabUrl: data.gitlabUrl || "",
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
toast.success("Gitlab updated successfully");
|
||||
setIsOpen(false);
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to update Gitlab");
|
||||
@@ -126,6 +133,19 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gitlabUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gitlab Url</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://gitlab.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
||||
@@ -23,12 +23,16 @@ export const ShowGitProviders = () => {
|
||||
|
||||
const url = useUrl();
|
||||
|
||||
const getGitlabUrl = (clientId: string, gitlabId: string) => {
|
||||
const getGitlabUrl = (
|
||||
clientId: string,
|
||||
gitlabId: string,
|
||||
gitlabUrl: string,
|
||||
) => {
|
||||
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
|
||||
|
||||
const scope = "api read_user read_repository";
|
||||
|
||||
const authUrl = `https://gitlab.com/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
|
||||
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
|
||||
|
||||
return authUrl;
|
||||
};
|
||||
@@ -142,6 +146,7 @@ export const ShowGitProviders = () => {
|
||||
href={getGitlabUrl(
|
||||
gitProvider.gitlab?.applicationId || "",
|
||||
gitProvider.gitlab?.gitlabId || "",
|
||||
gitProvider.gitlab?.gitlabUrl,
|
||||
)}
|
||||
target="_blank"
|
||||
className={buttonVariants({
|
||||
|
||||
@@ -667,7 +667,7 @@ export const AddNotification = () => {
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Dokploy Restart</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a dokploy is restarted.
|
||||
Trigger the action when dokploy is restarted.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -24,8 +24,13 @@ export const DeleteNotification = ({ notificationId }: Props) => {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" isLoading={isLoading}>
|
||||
<TrashIcon className="size-4 text-muted-foreground" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 group hover:bg-red-500/10"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<Trash2 className="size-4 text-muted-foreground group-hover:text-red-500" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -40,48 +40,58 @@ export const ShowNotifications = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{data?.map((notification, index) => (
|
||||
<div
|
||||
key={notification.notificationId}
|
||||
className="flex items-center justify-between border gap-2 p-3.5 rounded-lg"
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center w-full ">
|
||||
{notification.notificationType === "slack" && (
|
||||
<SlackIcon className="text-muted-foreground size-6 flex-shrink-0" />
|
||||
)}
|
||||
{notification.notificationType === "telegram" && (
|
||||
<TelegramIcon className="text-muted-foreground size-8 flex-shrink-0" />
|
||||
)}
|
||||
{notification.notificationType === "discord" && (
|
||||
<DiscordIcon className="text-muted-foreground size-7 flex-shrink-0" />
|
||||
)}
|
||||
{notification.notificationType === "email" && (
|
||||
<Mail
|
||||
size={29}
|
||||
className="text-muted-foreground size-6 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{notification.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1 w-fit">
|
||||
<UpdateNotification
|
||||
notificationId={notification.notificationId}
|
||||
/>
|
||||
<DeleteNotification
|
||||
notificationId={notification.notificationId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 justify-end w-full items-end">
|
||||
<AddNotification />
|
||||
<div className="grid lg:grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{data?.map((notification, index) => (
|
||||
<div
|
||||
key={notification.notificationId}
|
||||
className="flex items-center justify-between rounded-xl p-4 transition-colors dark:bg-zinc-900/50 hover:bg-zinc-900 border border-zinc-800/50"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{notification.notificationType === "slack" && (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
|
||||
<SlackIcon className="h-6 w-6 text-indigo-400" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "telegram" && (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-500/10">
|
||||
<TelegramIcon className="h-6 w-6 text-indigo-400" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "discord" && (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
|
||||
<DiscordIcon className="h-6 w-6 text-indigo-400" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "email" && (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-500/10">
|
||||
<Mail className="h-6 w-6 text-indigo-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-zinc-300">
|
||||
{notification.name}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{notification.notificationType?.[0]?.toUpperCase() + notification.notificationType?.slice(1)} notification
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<UpdateNotification
|
||||
notificationId={notification.notificationId}
|
||||
/>
|
||||
<DeleteNotification
|
||||
notificationId={notification.notificationId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 justify-end w-full items-end">
|
||||
<AddNotification />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Mail, PenBoxIcon } from "lucide-react";
|
||||
import { Mail, Pen } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FieldErrors, useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -218,8 +218,10 @@ export const UpdateNotification = ({ notificationId }: Props) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger className="" asChild>
|
||||
<Button variant="ghost">
|
||||
<PenBoxIcon className="size-4 text-muted-foreground" />
|
||||
<Button variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9">
|
||||
<Pen className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
|
||||
@@ -26,10 +26,12 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Disable2FA } from "./disable-2fa";
|
||||
import { Enable2FA } from "./enable-2fa";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
|
||||
const profileSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string().nullable(),
|
||||
currentPassword: z.string().nullable(),
|
||||
image: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -52,7 +54,8 @@ const randomImages = [
|
||||
|
||||
export const ProfileForm = () => {
|
||||
const { data, refetch } = api.auth.get.useQuery();
|
||||
const { mutateAsync, isLoading } = api.auth.update.useMutation();
|
||||
const { mutateAsync, isLoading, isError, error } =
|
||||
api.auth.update.useMutation();
|
||||
const { t } = useTranslation("settings");
|
||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||
|
||||
@@ -68,6 +71,7 @@ export const ProfileForm = () => {
|
||||
email: data?.email || "",
|
||||
password: "",
|
||||
image: data?.image || "",
|
||||
currentPassword: "",
|
||||
},
|
||||
resolver: zodResolver(profileSchema),
|
||||
});
|
||||
@@ -78,6 +82,7 @@ export const ProfileForm = () => {
|
||||
email: data?.email || "",
|
||||
password: "",
|
||||
image: data?.image || "",
|
||||
currentPassword: "",
|
||||
});
|
||||
|
||||
if (data.email) {
|
||||
@@ -94,10 +99,12 @@ export const ProfileForm = () => {
|
||||
email: values.email.toLowerCase(),
|
||||
password: values.password,
|
||||
image: values.image,
|
||||
currentPassword: values.currentPassword,
|
||||
})
|
||||
.then(async () => {
|
||||
await refetch();
|
||||
toast.success("Profile Updated");
|
||||
form.reset();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to Update the profile");
|
||||
@@ -116,6 +123,8 @@ export const ProfileForm = () => {
|
||||
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
||||
<div className="space-y-4">
|
||||
@@ -135,6 +144,24 @@ export const ProfileForm = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("settings.profile.password")}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const profileSchema = z.object({
|
||||
password: z.string().min(1, {
|
||||
message: "Password is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type Profile = z.infer<typeof profileSchema>;
|
||||
|
||||
export const RemoveSelfAccount = () => {
|
||||
const { data } = api.auth.get.useQuery();
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.auth.removeSelfAccount.useMutation();
|
||||
const { t } = useTranslation("settings");
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<Profile>({
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
resolver: zodResolver(profileSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
password: "",
|
||||
});
|
||||
}
|
||||
form.reset();
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (values: Profile) => {
|
||||
await mutateAsync({
|
||||
password: values.password,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Profile Deleted");
|
||||
router.push("/");
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Remove Self Account</CardTitle>
|
||||
<CardDescription>
|
||||
If you want to remove your account, you can do it here
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
className="grid gap-4"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("settings.profile.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("settings.profile.password")}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
<div>
|
||||
<DialogAction
|
||||
title="Are you sure you want to remove your account?"
|
||||
description="This action cannot be undone, all your projects/servers will be deleted."
|
||||
onClick={() => form.handleSubmit(onSubmit)()}
|
||||
>
|
||||
<Button type="button" isLoading={isLoading} variant="destructive">
|
||||
Remove
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
|
||||
import { ShowModalLogs } from "../../web-server/show-modal-logs";
|
||||
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
@@ -128,6 +129,14 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
<span>Enter the terminal</span>
|
||||
</DropdownMenuItem>
|
||||
</DockerTerminalModal> */}
|
||||
<ManageTraefikPorts serverId={serverId}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
|
||||
</DropdownMenuItem>
|
||||
</ManageTraefikPorts>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { FileTerminal } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
command: z.string().min(1, {
|
||||
message: "Command is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
export const EditScript = ({ serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: server } = api.server.one.useQuery(
|
||||
{
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading } = api.server.update.useMutation();
|
||||
|
||||
const { data: defaultCommand } = api.server.getDefaultCommand.useQuery(
|
||||
{
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
command: "",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (server) {
|
||||
form.reset({
|
||||
command: server.command || defaultCommand,
|
||||
});
|
||||
}
|
||||
}, [server, defaultCommand]);
|
||||
|
||||
const onSubmit = async (formData: Schema) => {
|
||||
if (server) {
|
||||
await mutateAsync({
|
||||
...server,
|
||||
command: formData.command || "",
|
||||
serverId,
|
||||
})
|
||||
.then((data) => {
|
||||
toast.success("Script modified successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error modifying the script");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
Modify Script
|
||||
<FileTerminal className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl overflow-x-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modify Script</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modify the script which install everything necessary to deploy
|
||||
applications on your server,
|
||||
</DialogDescription>
|
||||
|
||||
<AlertBlock type="warning">
|
||||
We recommend not modifying this script unless you know what you are doing.
|
||||
</AlertBlock>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-delete-application"
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="command"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl className="max-h-[75vh] max-w-[60rem] overflow-y-scroll overflow-x-hidden">
|
||||
<CodeEditor
|
||||
language="shell"
|
||||
wrapperClassName="font-mono"
|
||||
{...field}
|
||||
placeholder={`
|
||||
set -e
|
||||
echo "Hello world"
|
||||
`}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<DialogFooter className="flex justify-between w-full">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
command: defaultCommand || "",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-delete-application"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -262,16 +262,16 @@ export function StatusRow({
|
||||
<div className="flex items-center gap-2">
|
||||
{showIcon ? (
|
||||
<>
|
||||
{isEnabled ? (
|
||||
<CheckCircle2 className="size-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="size-4 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={`text-sm ${isEnabled ? "text-green-500" : "text-red-500"}`}
|
||||
>
|
||||
{description || (isEnabled ? "Installed" : "Not Installed")}
|
||||
</span>
|
||||
{isEnabled ? (
|
||||
<CheckCircle2 className="size-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="size-4 text-red-500" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{value}</span>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { Loader2, LockKeyhole, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { StatusRow } from "./gpu-support";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const SecurityAudit = ({ serverId }: Props) => {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const { data, refetch, error, isLoading, isError } =
|
||||
api.server.security.useQuery(
|
||||
{ serverId },
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<LockKeyhole className="size-5" />
|
||||
<CardTitle className="text-xl">
|
||||
Setup Security Sugestions
|
||||
</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Check the security sugestions</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isRefreshing}
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
await refetch();
|
||||
setIsRefreshing(false);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{isError && (
|
||||
<AlertBlock type="error" className="w-full">
|
||||
{error.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<AlertBlock type="info" className="w-full">
|
||||
Ubuntu/Debian OS support is currently supported (Experimental)
|
||||
</AlertBlock>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center text-muted-foreground py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Checking Server configuration</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid w-full gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-1">UFW</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
UFW (Uncomplicated Firewall) is a simple firewall that can
|
||||
be used to block incoming and outgoing traffic from your
|
||||
server.
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
label="UFW Installed"
|
||||
isEnabled={data?.ufw?.installed}
|
||||
description={
|
||||
data?.ufw?.installed
|
||||
? "Installed (Recommended)"
|
||||
: "Not Installed (UFW should be installed for security)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Status"
|
||||
isEnabled={data?.ufw?.active}
|
||||
description={
|
||||
data?.ufw?.active
|
||||
? "Active (Recommended)"
|
||||
: "Not Active (UFW should be enabled for security)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Default Incoming"
|
||||
isEnabled={data?.ufw?.defaultIncoming === "deny"}
|
||||
description={
|
||||
data?.ufw?.defaultIncoming === "deny"
|
||||
? "Default: Deny (Recommended)"
|
||||
: `Default: ${data?.ufw?.defaultIncoming} (Should be set to 'deny' for security)`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-1">SSH</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
SSH (Secure Shell) is a protocol that allows you to securely
|
||||
connect to a server and execute commands on it.
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
label="Enabled"
|
||||
isEnabled={data?.ssh.enabled}
|
||||
description={
|
||||
data?.ssh.enabled
|
||||
? "Enabled"
|
||||
: "Not Enabled (SSH should be enabled)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Key Auth"
|
||||
isEnabled={data?.ssh.keyAuth}
|
||||
description={
|
||||
data?.ssh.keyAuth
|
||||
? "Enabled (Recommended)"
|
||||
: "Not Enabled (Key Authentication should be enabled)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Password Auth"
|
||||
isEnabled={data?.ssh.passwordAuth === "no"}
|
||||
description={
|
||||
data?.ssh.passwordAuth === "no"
|
||||
? "Disabled (Recommended)"
|
||||
: "Enabled (Password Authentication should be disabled)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Permit Root Login"
|
||||
isEnabled={data?.ssh.permitRootLogin === "no"}
|
||||
description={
|
||||
data?.ssh.permitRootLogin === "no"
|
||||
? "Disabled (Recommended)"
|
||||
: `Enabled: ${data?.ssh.permitRootLogin} (Root Login should be disabled)`
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Use PAM"
|
||||
isEnabled={data?.ssh.usePam === "no"}
|
||||
description={
|
||||
data?.ssh.usePam === "no"
|
||||
? "Disabled (Recommended for key-based auth)"
|
||||
: "Enabled (Should be disabled when using key-based auth)"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-1">Fail2Ban</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Fail2Ban (Fail2Ban) is a service that can be used to prevent
|
||||
brute force attacks on your server.
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
label="Installed"
|
||||
isEnabled={data?.fail2ban.installed}
|
||||
description={
|
||||
data?.fail2ban.installed
|
||||
? "Installed (Recommended)"
|
||||
: "Not Installed (Fail2Ban should be installed for protection against brute force attacks)"
|
||||
}
|
||||
/>
|
||||
|
||||
<StatusRow
|
||||
label="Enabled"
|
||||
isEnabled={data?.fail2ban.enabled}
|
||||
description={
|
||||
data?.fail2ban.enabled
|
||||
? "Enabled (Recommended)"
|
||||
: "Not Enabled (Fail2Ban service should be enabled)"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Active"
|
||||
isEnabled={data?.fail2ban.active}
|
||||
description={
|
||||
data?.fail2ban.active
|
||||
? "Active (Recommended)"
|
||||
: "Not Active (Fail2Ban service should be running)"
|
||||
}
|
||||
/>
|
||||
|
||||
<StatusRow
|
||||
label="SSH Protection"
|
||||
isEnabled={data?.fail2ban.sshEnabled === "true"}
|
||||
description={
|
||||
data?.fail2ban.sshEnabled === "true"
|
||||
? "Enabled (Recommended)"
|
||||
: "Not Enabled (SSH protection should be enabled to prevent brute force attacks)"
|
||||
}
|
||||
/>
|
||||
|
||||
<StatusRow
|
||||
label="SSH Mode"
|
||||
isEnabled={data?.fail2ban.sshMode === "aggressive"}
|
||||
description={
|
||||
data?.fail2ban.sshMode === "aggressive"
|
||||
? "Aggressive Mode (Recommended)"
|
||||
: `Mode: ${data?.fail2ban.sshMode || "Not Set"} (Aggressive mode recommended for better protection)`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
@@ -32,7 +32,10 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ShowDeployment } from "../../application/deployments/show-deployment";
|
||||
import { EditScript } from "./edit-script";
|
||||
import { GPUSupport } from "./gpu-support";
|
||||
import { ValidateServer } from "./validate-server";
|
||||
import { SecurityAudit } from "./security-audit";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
@@ -88,11 +91,18 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
</AlertBlock>
|
||||
</div>
|
||||
) : (
|
||||
<div id="hook-form-add-gitlab" className="grid w-full gap-1">
|
||||
<div id="hook-form-add-gitlab" className="grid w-full gap-4">
|
||||
<AlertBlock type="warning">
|
||||
Using a root user is required to ensure everything works as
|
||||
expected.
|
||||
</AlertBlock>
|
||||
|
||||
<Tabs defaultValue="ssh-keys">
|
||||
<TabsList className="grid grid-cols-3 w-[400px]">
|
||||
<TabsList className="grid grid-cols-5 w-[700px]">
|
||||
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="validate">Validate</TabsTrigger>
|
||||
<TabsTrigger value="audit">Security</TabsTrigger>
|
||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
@@ -137,7 +147,7 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
Automatic process
|
||||
</span>
|
||||
<Link
|
||||
href="https://docs.dokploy.com/en/docs/core/get-started/introduction"
|
||||
href="https://docs.dokploy.com/docs/core/multi-server/instructions#requirements"
|
||||
target="_blank"
|
||||
className="text-primary flex flex-row gap-2"
|
||||
>
|
||||
@@ -196,6 +206,28 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
|
||||
<span className="text-base font-semibold text-primary">
|
||||
Supported Distros:
|
||||
</span>
|
||||
<p>
|
||||
We strongly recommend to use the following distros to
|
||||
ensure the best experience:
|
||||
</p>
|
||||
<ul>
|
||||
<li>1. Ubuntu 24.04 LTS</li>
|
||||
<li>2. Ubuntu 23.10 LTS </li>
|
||||
<li>3. Ubuntu 22.04 LTS</li>
|
||||
<li>4. Ubuntu 20.04 LTS</li>
|
||||
<li>5. Ubuntu 18.04 LTS</li>
|
||||
<li>6. Debian 12</li>
|
||||
<li>7. Debian 11</li>
|
||||
<li>8. Debian 10</li>
|
||||
<li>9. Fedora 40</li>
|
||||
<li>10. Centos 9</li>
|
||||
<li>11. Centos 8</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="deployments">
|
||||
@@ -203,7 +235,7 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-row gap-2 justify-between w-full items-end max-sm:flex-col">
|
||||
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">
|
||||
Deployments
|
||||
@@ -212,24 +244,29 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
See all the 5 Server Setup
|
||||
</CardDescription>
|
||||
</div>
|
||||
<DialogAction
|
||||
title={"Setup Server?"}
|
||||
description="This will setup the server and all associated data"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
serverId: server?.serverId || "",
|
||||
})
|
||||
.then(async () => {
|
||||
refetch();
|
||||
toast.success("Server setup successfully");
|
||||
<div className="flex flex-row gap-2">
|
||||
<EditScript serverId={server?.serverId || ""} />
|
||||
<DialogAction
|
||||
title={"Setup Server?"}
|
||||
description="This will setup the server and all associated data"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
serverId: server?.serverId || "",
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error configuring server");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button isLoading={isLoading}>Setup Server</Button>
|
||||
</DialogAction>
|
||||
.then(async () => {
|
||||
refetch();
|
||||
toast.success("Server setup successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error configuring server");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button isLoading={isLoading}>
|
||||
Setup Server
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
@@ -293,6 +330,22 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
</div>
|
||||
</CardContent>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="validate"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||
<ValidateServer serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="audit"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||
<SecurityAudit serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="gpu-setup"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
|
||||
@@ -31,8 +31,12 @@ import { SetupServer } from "./setup-server";
|
||||
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
|
||||
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
||||
import { UpdateServer } from "./update-server";
|
||||
import { useRouter } from "next/router";
|
||||
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
||||
|
||||
export const ShowServers = () => {
|
||||
const router = useRouter();
|
||||
const query = router.query;
|
||||
const { data, refetch } = api.server.all.useQuery();
|
||||
const { mutateAsync } = api.server.remove.useMutation();
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
@@ -42,12 +46,26 @@ export const ShowServers = () => {
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{query?.success && isCloud && <WelcomeSuscription />}
|
||||
<div className="space-y-2 flex flex-row justify-between items-end">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Servers</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Add servers to deploy your applications remotely.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Servers</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Add servers to deploy your applications remotely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isCloud && (
|
||||
<span
|
||||
className="text-primary cursor-pointer text-sm"
|
||||
onClick={() => {
|
||||
router.push("/dashboard/settings/servers?success=true");
|
||||
}}
|
||||
>
|
||||
Reset Onboarding
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sshKeys && sshKeys?.length > 0 && (
|
||||
@@ -100,7 +118,9 @@ export const ShowServers = () => {
|
||||
{data && data?.length > 0 && (
|
||||
<div className="flex flex-col gap-6 overflow-auto">
|
||||
<Table>
|
||||
<TableCaption>See all servers</TableCaption>
|
||||
<TableCaption>
|
||||
<div className="flex flex-col gap-4">See all servers</div>
|
||||
</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">Name</TableHead>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { Loader2, PcCase, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { StatusRow } from "./gpu-support";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const ValidateServer = ({ serverId }: Props) => {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const { data, refetch, error, isLoading, isError } =
|
||||
api.server.validate.useQuery(
|
||||
{ serverId },
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<PcCase className="size-5" />
|
||||
<CardTitle className="text-xl">Setup Validation</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Check if your server is ready for deployment
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isRefreshing}
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
await refetch();
|
||||
setIsRefreshing(false);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{isError && (
|
||||
<AlertBlock type="error" className="w-full">
|
||||
{error.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center text-muted-foreground py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Checking Server configuration</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid w-full gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-1">Status</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Shows the server configuration status
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
label="Docker Installed"
|
||||
isEnabled={data?.docker?.enabled}
|
||||
description={
|
||||
data?.docker?.enabled
|
||||
? `Installed: ${data?.docker?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="RClone Installed"
|
||||
isEnabled={data?.rclone?.enabled}
|
||||
description={
|
||||
data?.rclone?.enabled
|
||||
? `Installed: ${data?.rclone?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Nixpacks Installed"
|
||||
isEnabled={data?.nixpacks?.enabled}
|
||||
description={
|
||||
data?.nixpacks?.enabled
|
||||
? `Installed: ${data?.nixpacks?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Buildpacks Installed"
|
||||
isEnabled={data?.buildpacks?.enabled}
|
||||
description={
|
||||
data?.buildpacks?.enabled
|
||||
? `Installed: ${data?.buildpacks?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Docker Swarm Initialized"
|
||||
isEnabled={data?.isSwarmInstalled}
|
||||
description={
|
||||
data?.isSwarmInstalled
|
||||
? "Initialized"
|
||||
: "Not Initialized"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Dokploy Network Created"
|
||||
isEnabled={data?.isDokployNetworkInstalled}
|
||||
description={
|
||||
data?.isDokployNetworkInstalled
|
||||
? "Created"
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Main Directory Created"
|
||||
isEnabled={data?.isMainDirectoryInstalled}
|
||||
description={
|
||||
data?.isMainDirectoryInstalled
|
||||
? "Created"
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { DialogFooter } from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
ipAddress: z.string().min(1, {
|
||||
message: "IP Address is required",
|
||||
}),
|
||||
port: z.number().optional(),
|
||||
username: z.string().optional(),
|
||||
sshKeyId: z.string().min(1, {
|
||||
message: "SSH Key is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
interface Props {
|
||||
stepper: any;
|
||||
}
|
||||
|
||||
export const CreateServer = ({ stepper }: Props) => {
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: canCreateMoreServers, refetch } =
|
||||
api.stripe.canCreateMoreServers.useQuery();
|
||||
const { mutateAsync, error, isError } = api.server.create.useMutation();
|
||||
const cloudSSHKey = sshKeys?.find(
|
||||
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
|
||||
);
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
description: "Dokploy Cloud Server",
|
||||
name: "My First Server",
|
||||
ipAddress: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: cloudSSHKey?.sshKeyId || "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
description: "Dokploy Cloud Server",
|
||||
name: "My First Server",
|
||||
ipAddress: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: cloudSSHKey?.sshKeyId || "",
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, sshKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [isOpen]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
await mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
ipAddress: data.ipAddress || "",
|
||||
port: data.port || 22,
|
||||
username: data.username || "root",
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
})
|
||||
.then(async (data) => {
|
||||
toast.success("Server Created");
|
||||
stepper.next();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error to create a server");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Card className="bg-background flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 pt-5 px-4">
|
||||
{!canCreateMoreServers && (
|
||||
<AlertBlock type="warning">
|
||||
You cannot create more servers,{" "}
|
||||
<Link href="/dashboard/settings/billing" className="text-primary">
|
||||
Please upgrade your plan
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardContent className="flex flex-col">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-server"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 ">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Hostinger Server" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="This server is for databases..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshKeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Select a SSH Key</FormLabel>
|
||||
{!cloudSSHKey && (
|
||||
<AlertBlock>
|
||||
Looks like you didn't have the SSH Key yet, you can create
|
||||
one{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/ssh-keys"
|
||||
className="text-primary"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a SSH Key" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{sshKeys?.map((sshKey) => (
|
||||
<SelectItem
|
||||
key={sshKey.sshKeyId}
|
||||
value={sshKey.sshKeyId}
|
||||
>
|
||||
{sshKey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Registries ({sshKeys?.length})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ipAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IP Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="192.168.1.100" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="22"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange(0);
|
||||
} else {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(number)) {
|
||||
field.onChange(number);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="root" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter className="pt-5">
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
disabled={!canCreateMoreServers}
|
||||
form="hook-form-add-server"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { ExternalLinkIcon, Loader2 } from "lucide-react";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import Link from "next/link";
|
||||
|
||||
export const CreateSSHKey = () => {
|
||||
const { data, refetch } = api.sshKey.all.useQuery();
|
||||
const generateMutation = api.sshKey.generate.useMutation();
|
||||
const { mutateAsync, isLoading } = api.sshKey.create.useMutation();
|
||||
const hasCreatedKey = useRef(false);
|
||||
|
||||
const cloudSSHKey = data?.find(
|
||||
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const createKey = async () => {
|
||||
if (!data || cloudSSHKey || hasCreatedKey.current || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasCreatedKey.current = true;
|
||||
|
||||
try {
|
||||
const keys = await generateMutation.mutateAsync({
|
||||
type: "rsa",
|
||||
});
|
||||
await mutateAsync({
|
||||
name: "dokploy-cloud-ssh-key",
|
||||
description: "Used on Dokploy Cloud",
|
||||
privateKey: keys.privateKey,
|
||||
publicKey: keys.publicKey,
|
||||
});
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error("Error creating SSH key:", error);
|
||||
hasCreatedKey.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
createKey();
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-transparent">
|
||||
<CardContent>
|
||||
<div className="grid w-full gap-4 pt-4">
|
||||
{isLoading || !cloudSSHKey ? (
|
||||
<div className="min-h-[25vh] justify-center flex items-center gap-4">
|
||||
<Loader2
|
||||
className="animate-spin text-muted-foreground"
|
||||
size={32}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground">
|
||||
<p className="text-primary text-base font-semibold">
|
||||
You have two options to add SSH Keys to your server:
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>1. Add The SSH Key to Server Manually</li>
|
||||
|
||||
<li>
|
||||
2. Add the public SSH Key when you create a server in your
|
||||
preffered provider (Hostinger, Digital Ocean, Hetzner, etc){" "}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
|
||||
<span className="text-base font-semibold text-primary">
|
||||
Option 1
|
||||
</span>
|
||||
<ul>
|
||||
<li className="items-center flex gap-1">
|
||||
1. Login to your server{" "}
|
||||
</li>
|
||||
<li>
|
||||
2. When you are logged in run the following command
|
||||
<div className="flex relative flex-col gap-4 w-full mt-2">
|
||||
<CodeEditor
|
||||
lineWrapping
|
||||
language="properties"
|
||||
value={`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`}
|
||||
readOnly
|
||||
className="font-mono opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li className="mt-1">
|
||||
3. You're done, follow the next step to insert the details
|
||||
of your server.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full mt-2 border rounded-lg p-4">
|
||||
<span className="text-base font-semibold text-primary">
|
||||
Option 2
|
||||
</span>
|
||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||
<div className="flex relative flex-col gap-2 overflow-y-auto">
|
||||
<div className="text-sm text-primary flex flex-row gap-2 items-center">
|
||||
Copy Public Key
|
||||
<button
|
||||
type="button"
|
||||
className=" right-2 top-8"
|
||||
onClick={() => {
|
||||
copy(
|
||||
cloudSSHKey?.publicKey || "Generate a SSH Key",
|
||||
);
|
||||
toast.success("SSH Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href="https://docs.dokploy.com/docs/core/multi-server/instructions#requirements"
|
||||
target="_blank"
|
||||
className="text-primary flex flex-row gap-2"
|
||||
>
|
||||
View Tutorial <ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import { ShowDeployment } from "@/components/dashboard/application/deployments/show-deployment";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { RocketIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { EditScript } from "../edit-script";
|
||||
import { api } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export const Setup = () => {
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const [serverId, setServerId] = useState<string>(
|
||||
servers?.[0]?.serverId || "",
|
||||
);
|
||||
const { data: server } = api.server.one.useQuery(
|
||||
{
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
|
||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||
const { data: deployments, refetch } = api.deployment.allByServer.useQuery(
|
||||
{ serverId },
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading } = api.server.setup.useMutation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Label>Select the server and click on setup server</Label>
|
||||
<Select onValueChange={setServerId} defaultValue={serverId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{servers?.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Deployments</CardTitle>
|
||||
<CardDescription>See all the 5 Server Setup</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
<EditScript serverId={server?.serverId || ""} />
|
||||
<DialogAction
|
||||
title={"Setup Server?"}
|
||||
description="This will setup the server and all associated data"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
serverId: server?.serverId || "",
|
||||
})
|
||||
.then(async () => {
|
||||
refetch();
|
||||
toast.success("Server setup successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error configuring server");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button isLoading={isLoading}>Setup Server</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 min-h-[30vh]">
|
||||
{server?.deployments?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<RocketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No deployments found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{deployments?.map((deployment) => (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{deployment.status}
|
||||
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.title}
|
||||
</span>
|
||||
{deployment.description && (
|
||||
<span className="break-all text-sm text-muted-foreground">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment.logPath);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ShowDeployment
|
||||
open={activeLog !== null}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import { Loader2, PcCase, RefreshCw } from "lucide-react";
|
||||
import { api } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
} from "@/components/ui/select";
|
||||
import { StatusRow } from "../gpu-support";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
|
||||
export const Verify = () => {
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const [serverId, setServerId] = useState<string>(
|
||||
servers?.[0]?.serverId || "",
|
||||
);
|
||||
const { data, refetch, error, isLoading, isError } =
|
||||
api.server.validate.useQuery(
|
||||
{ serverId },
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const { data: server } = api.server.one.useQuery(
|
||||
{
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Label>Select a server</Label>
|
||||
<Select onValueChange={setServerId} defaultValue={serverId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{servers?.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<PcCase className="size-5" />
|
||||
<CardTitle className="text-xl">Setup Validation</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Check if your server is ready for deployment
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isRefreshing}
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
await refetch();
|
||||
setIsRefreshing(false);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{isError && (
|
||||
<AlertBlock type="error" className="w-full">
|
||||
{error.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4 min-h-[25vh]">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center text-muted-foreground py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Checking Server configuration</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid w-full gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-1">Status</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Shows the server configuration status
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
label="Docker Installed"
|
||||
isEnabled={data?.docker?.enabled}
|
||||
description={
|
||||
data?.docker?.enabled
|
||||
? `Installed: ${data?.docker?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="RClone Installed"
|
||||
isEnabled={data?.rclone?.enabled}
|
||||
description={
|
||||
data?.rclone?.enabled
|
||||
? `Installed: ${data?.rclone?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Nixpacks Installed"
|
||||
isEnabled={data?.nixpacks?.enabled}
|
||||
description={
|
||||
data?.nixpacks?.enabled
|
||||
? `Installed: ${data?.nixpacks?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Buildpacks Installed"
|
||||
isEnabled={data?.buildpacks?.enabled}
|
||||
description={
|
||||
data?.buildpacks?.enabled
|
||||
? `Installed: ${data?.buildpacks?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Docker Swarm Initialized"
|
||||
isEnabled={data?.isSwarmInstalled}
|
||||
description={
|
||||
data?.isSwarmInstalled
|
||||
? "Initialized"
|
||||
: "Not Initialized"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Dokploy Network Created"
|
||||
isEnabled={data?.isDokployNetworkInstalled}
|
||||
description={
|
||||
data?.isDokployNetworkInstalled
|
||||
? "Created"
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Main Directory Created"
|
||||
isEnabled={data?.isMainDirectoryInstalled}
|
||||
description={
|
||||
data?.isMainDirectoryInstalled
|
||||
? "Created"
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,411 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { BookIcon, Puzzle } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { defineStepper } from "@stepperize/react";
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CreateServer } from "./create-server";
|
||||
import { CreateSSHKey } from "./create-ssh-key";
|
||||
import { Setup } from "./setup";
|
||||
import { Verify } from "./verify";
|
||||
import { Database, Globe, GitMerge, Users, Code2, Plug } from "lucide-react";
|
||||
import ConfettiExplosion from "react-confetti-explosion";
|
||||
import Link from "next/link";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
|
||||
export const { useStepper, steps, Scoped } = defineStepper(
|
||||
{
|
||||
id: "requisites",
|
||||
title: "Requisites",
|
||||
description: "Check your requisites",
|
||||
},
|
||||
{
|
||||
id: "create-ssh-key",
|
||||
title: "SSH Key",
|
||||
description: "Create your ssh key",
|
||||
},
|
||||
{
|
||||
id: "connect-server",
|
||||
title: "Connect",
|
||||
description: "Connect",
|
||||
},
|
||||
{ id: "setup", title: "Setup", description: "Setup your server" },
|
||||
{ id: "verify", title: "Verify", description: "Verify your server" },
|
||||
{ id: "complete", title: "Complete", description: "Checkout complete" },
|
||||
);
|
||||
|
||||
export const WelcomeSuscription = () => {
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const stepper = useStepper();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const { push } = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const confettiShown = localStorage.getItem("hasShownConfetti");
|
||||
if (!confettiShown) {
|
||||
setShowConfetti(true);
|
||||
localStorage.setItem("hasShownConfetti", "true");
|
||||
}
|
||||
}, [showConfetti]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl min-h-[75vh]">
|
||||
{showConfetti ?? "Flaso"}
|
||||
<div className="flex justify-center items-center w-full">
|
||||
{showConfetti && (
|
||||
<ConfettiExplosion
|
||||
duration={3000}
|
||||
force={0.3}
|
||||
particleSize={12}
|
||||
particleCount={300}
|
||||
className="z-[9999]"
|
||||
zIndex={9999}
|
||||
width={1500}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-center">
|
||||
Welcome To Dokploy Cloud 🎉
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center max-w-xl mx-auto">
|
||||
Thank you for choosing Dokploy Cloud! 🚀 We're excited to have you
|
||||
onboard. Before you dive in, you'll need to configure your remote
|
||||
server to unlock all the features we offer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-lg font-semibold">Steps</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Step {stepper.current.index + 1} of {steps.length}
|
||||
</span>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
<Scoped>
|
||||
<nav aria-label="Checkout Steps" className="group my-4">
|
||||
<ol
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-orientation="horizontal"
|
||||
>
|
||||
{stepper.all.map((step, index, array) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<li className="flex items-center gap-4 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
role="tab"
|
||||
variant={
|
||||
index <= stepper.current.index ? "secondary" : "ghost"
|
||||
}
|
||||
aria-current={
|
||||
stepper.current.id === step.id ? "step" : undefined
|
||||
}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={steps.length}
|
||||
aria-selected={stepper.current.id === step.id}
|
||||
className="flex size-10 items-center justify-center rounded-full border-2 border-border"
|
||||
onClick={() => stepper.goTo(step.id)}
|
||||
>
|
||||
{index + 1}
|
||||
</Button>
|
||||
<span className="text-sm font-medium">{step.title}</span>
|
||||
</li>
|
||||
{index < array.length - 1 && (
|
||||
<Separator
|
||||
className={`flex-1 ${
|
||||
index < stepper.current.index
|
||||
? "bg-primary"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
{stepper.switch({
|
||||
requisites: () => (
|
||||
<div className="flex flex-col gap-2 border p-4 rounded-lg">
|
||||
<span className="text-primary text-base font-bold">
|
||||
Before getting started, please follow the steps below to
|
||||
ensure the best experience:
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-primary text-sm font-medium">
|
||||
Supported Distributions:
|
||||
</p>
|
||||
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
|
||||
<li>Ubuntu 24.04 LTS</li>
|
||||
<li>Ubuntu 23.10</li>
|
||||
<li>Ubuntu 22.04 LTS</li>
|
||||
<li>Ubuntu 20.04 LTS</li>
|
||||
<li>Ubuntu 18.04 LTS</li>
|
||||
<li>Debian 12</li>
|
||||
<li>Debian 11</li>
|
||||
<li>Debian 10</li>
|
||||
<li>Fedora 40</li>
|
||||
<li>CentOS 9</li>
|
||||
<li>CentOS 8</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-primary text-sm font-medium">
|
||||
You will need to purchase or rent a Virtual Private Server
|
||||
(VPS) to proceed, we recommend to use one of these
|
||||
providers since has been heavily tested.
|
||||
</p>
|
||||
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97"
|
||||
className="text-link underline"
|
||||
>
|
||||
Hostinger - Get 20% Discount
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://m.do.co/c/db24efd43f35"
|
||||
className="text-link underline"
|
||||
>
|
||||
DigitalOcean - Get $200 Credits
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://hetzner.cloud/?ref=vou4fhxJ1W2D"
|
||||
className="text-link underline"
|
||||
>
|
||||
Hetzner - Get €20 Credits
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.vultr.com/?ref=9679828"
|
||||
className="text-link underline"
|
||||
>
|
||||
Vultr
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.linode.com/es/pricing/#compute-shared"
|
||||
className="text-link underline"
|
||||
>
|
||||
Linode
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<AlertBlock className="mt-4 px-4">
|
||||
You are free to use whatever provider, but we recommend to
|
||||
use one of the above, to avoid issues.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
"create-ssh-key": () => <CreateSSHKey />,
|
||||
"connect-server": () => <CreateServer stepper={stepper} />,
|
||||
setup: () => <Setup />,
|
||||
verify: () => <Verify />,
|
||||
complete: () => {
|
||||
const features = [
|
||||
{
|
||||
title: "Scalable Deployments",
|
||||
description:
|
||||
"Deploy and scale your applications effortlessly to handle any workload.",
|
||||
icon: <Database className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Automated Backups",
|
||||
description: "Protect your data with automatic backups",
|
||||
icon: <Database className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Open Source Templates",
|
||||
description:
|
||||
"Big list of common open source templates in one-click",
|
||||
icon: <Puzzle className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Custom Domains",
|
||||
description:
|
||||
"Link your own domains to your applications for a professional presence.",
|
||||
icon: <Globe className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "CI/CD Integration",
|
||||
description:
|
||||
"Implement continuous integration and deployment workflows to streamline development.",
|
||||
icon: <GitMerge className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Database Management",
|
||||
description:
|
||||
"Efficiently manage your databases with intuitive tools.",
|
||||
icon: <Database className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Team Collaboration",
|
||||
description:
|
||||
"Collaborate with your team on shared projects with customizable permissions.",
|
||||
icon: <Users className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Multi-language Support",
|
||||
description:
|
||||
"Deploy applications in multiple programming languages to suit your needs.",
|
||||
icon: <Code2 className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "API Access",
|
||||
description:
|
||||
"Integrate and manage your applications via robust and well-documented APIs.",
|
||||
icon: <Plug className="text-primary" />,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">You're All Set!</h2>
|
||||
<p className=" text-muted-foreground">
|
||||
Did you know you can deploy any number of applications
|
||||
that your server can handle?
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Here are some of the things you can do with Dokploy
|
||||
Cloud:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex flex-col items-start p-4 bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="text-3xl mb-2">{feature.icon}</div>
|
||||
<h3 className="text-lg font-medium mb-1">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<span className="text-base text-primary">
|
||||
Need Help? We are here to help you.
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Join to our Discord server and we will help you.
|
||||
</span>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Button className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0] w-fit">
|
||||
<Link
|
||||
href="https://discord.gg/2tBnJ3jDJc"
|
||||
aria-label="Dokploy on GitHub"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2 text-white"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
className="h-6 w-6 fill-white"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
Join Discord
|
||||
</Link>
|
||||
</Button>
|
||||
<Button className="rounded-full w-fit">
|
||||
<Link
|
||||
href="https://github.com/Dokploy/dokploy"
|
||||
aria-label="Dokploy on GitHub"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2 "
|
||||
>
|
||||
<GithubIcon />
|
||||
Github
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="rounded-full w-fit"
|
||||
variant="outline"
|
||||
>
|
||||
<Link
|
||||
href="https://docs.dokploy.com/docs/core"
|
||||
aria-label="Dokploy Docs"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2 "
|
||||
>
|
||||
<BookIcon size={16} />
|
||||
Docs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
})}
|
||||
</Scoped>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{!stepper.isLast && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
push("/dashboard/settings/servers");
|
||||
}}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 w-full justify-end">
|
||||
<Button
|
||||
onClick={stepper.prev}
|
||||
disabled={stepper.isFirst}
|
||||
variant="secondary"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (stepper.isLast) {
|
||||
setIsOpen(false);
|
||||
push("/dashboard/projects");
|
||||
} else {
|
||||
stepper.next();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{stepper.isLast ? "Complete" : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -49,14 +51,34 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
|
||||
},
|
||||
);
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
const [mainDialogOpen, setMainDialogOpen] = useState(false);
|
||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
|
||||
const handleMainDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setConfirmDialogOpen(true);
|
||||
} else {
|
||||
setMainDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setConfirmDialogOpen(false);
|
||||
setMainDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setConfirmDialogOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data?.length > 0) {
|
||||
setContainerId(data[0]?.containerId);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-7xl">
|
||||
<DialogHeader>
|
||||
@@ -96,6 +118,24 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
|
||||
id="terminal"
|
||||
containerId={containerId || "select-a-container"}
|
||||
/>
|
||||
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Are you sure you want to close the terminal?
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
By clicking the confirm button, the terminal will be closed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>Confirm</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Props for the ManageTraefikPorts component
|
||||
* @interface Props
|
||||
* @property {React.ReactNode} children - The trigger element that opens the ports management modal
|
||||
* @property {string} [serverId] - Optional ID of the server whose ports are being managed
|
||||
*/
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a port mapping configuration for Traefik
|
||||
* @interface AdditionalPort
|
||||
* @property {number} targetPort - The internal port that the service is listening on
|
||||
* @property {number} publishedPort - The external port that will be exposed
|
||||
* @property {"ingress" | "host"} publishMode - The Docker Swarm publish mode:
|
||||
* - "host": Publishes the port directly on the host
|
||||
* - "ingress": Publishes the port through the Swarm routing mesh
|
||||
*/
|
||||
interface AdditionalPort {
|
||||
targetPort: number;
|
||||
publishedPort: number;
|
||||
publishMode: "ingress" | "host";
|
||||
}
|
||||
|
||||
/**
|
||||
* ManageTraefikPorts is a component that provides a modal interface for managing
|
||||
* additional port mappings for Traefik in a Docker Swarm environment.
|
||||
*
|
||||
* Features:
|
||||
* - Add, remove, and edit port mappings
|
||||
* - Configure target port, published port, and publish mode for each mapping
|
||||
* - Persist port configurations through API calls
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ManageTraefikPorts serverId="server-123">
|
||||
* <Button>Manage Ports</Button>
|
||||
* </ManageTraefikPorts>
|
||||
* ```
|
||||
*/
|
||||
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
const { t } = useTranslation("settings");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [additionalPorts, setAdditionalPorts] = useState<AdditionalPort[]>([]);
|
||||
|
||||
const { data: currentPorts, refetch: refetchPorts } =
|
||||
api.settings.getTraefikPorts.useQuery({
|
||||
serverId,
|
||||
});
|
||||
|
||||
const { mutateAsync: updatePorts, isLoading } =
|
||||
api.settings.updateTraefikPorts.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchPorts();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPorts) {
|
||||
setAdditionalPorts(currentPorts);
|
||||
}
|
||||
}, [currentPorts]);
|
||||
|
||||
const handleAddPort = () => {
|
||||
setAdditionalPorts([
|
||||
...additionalPorts,
|
||||
{ targetPort: 0, publishedPort: 0, publishMode: "host" },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleUpdatePorts = async () => {
|
||||
try {
|
||||
await updatePorts({
|
||||
serverId,
|
||||
additionalPorts,
|
||||
});
|
||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t("settings.server.webServer.traefik.portsUpdateError"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={() => setOpen(true)}>{children}</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("settings.server.webServer.traefik.managePorts")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("settings.server.webServer.traefik.managePortsDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{additionalPorts.map((port, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-[120px_120px_minmax(120px,1fr)_80px] gap-4 items-end"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`target-port-${index}`}>
|
||||
{t("settings.server.webServer.traefik.targetPort")}
|
||||
</Label>
|
||||
<input
|
||||
id={`target-port-${index}`}
|
||||
type="number"
|
||||
value={port.targetPort}
|
||||
onChange={(e) => {
|
||||
const newPorts = [...additionalPorts];
|
||||
|
||||
if (newPorts[index]) {
|
||||
newPorts[index].targetPort = Number.parseInt(
|
||||
e.target.value,
|
||||
);
|
||||
}
|
||||
|
||||
setAdditionalPorts(newPorts);
|
||||
}}
|
||||
className="w-full rounded border p-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`published-port-${index}`}>
|
||||
{t("settings.server.webServer.traefik.publishedPort")}
|
||||
</Label>
|
||||
<input
|
||||
id={`published-port-${index}`}
|
||||
type="number"
|
||||
value={port.publishedPort}
|
||||
onChange={(e) => {
|
||||
const newPorts = [...additionalPorts];
|
||||
if (newPorts[index]) {
|
||||
newPorts[index].publishedPort = Number.parseInt(
|
||||
e.target.value,
|
||||
);
|
||||
}
|
||||
setAdditionalPorts(newPorts);
|
||||
}}
|
||||
className="w-full rounded border p-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`publish-mode-${index}`}>
|
||||
{t("settings.server.webServer.traefik.publishMode")}
|
||||
</Label>
|
||||
<Select
|
||||
value={port.publishMode}
|
||||
onValueChange={(value: "ingress" | "host") => {
|
||||
const newPorts = [...additionalPorts];
|
||||
|
||||
if (newPorts[index]) {
|
||||
newPorts[index].publishMode = value;
|
||||
}
|
||||
setAdditionalPorts(newPorts);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={`publish-mode-${index}`}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="host">Host</SelectItem>
|
||||
<SelectItem value="ingress">Ingress</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const newPorts = additionalPorts.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
setAdditionalPorts(newPorts);
|
||||
}}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-4 flex justify-between">
|
||||
<Button onClick={handleAddPort} variant="outline" size="sm">
|
||||
{t("settings.server.webServer.traefik.addPort")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpdatePorts}
|
||||
size="sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -91,11 +91,7 @@ export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DockerLogsId
|
||||
id="terminal"
|
||||
containerId={containerId || ""}
|
||||
serverId={serverId}
|
||||
/>
|
||||
<DockerLogsId containerId={containerId || ""} serverId={serverId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { AttachAddon } from "@xterm/addon-attach";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -12,7 +13,7 @@ interface Props {
|
||||
|
||||
export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
const termRef = useRef(null);
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(id);
|
||||
if (container) {
|
||||
@@ -20,13 +21,12 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
}
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
lineHeight: 1.4,
|
||||
convertEol: true,
|
||||
theme: {
|
||||
cursor: "transparent",
|
||||
background: "#19191A",
|
||||
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
|
||||
background: "rgba(0, 0, 0, 0)",
|
||||
foreground: "currentColor",
|
||||
},
|
||||
});
|
||||
const addonFit = new FitAddon();
|
||||
@@ -40,6 +40,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
|
||||
// @ts-ignore
|
||||
term.open(termRef.current);
|
||||
// @ts-ignore
|
||||
term.loadAddon(addonFit);
|
||||
term.loadAddon(addonAttach);
|
||||
addonFit.fit();
|
||||
@@ -50,7 +51,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="w-full h-full bg-input rounded-lg p-2 ">
|
||||
<div className="w-full h-full bg-transparent border rounded-lg p-2 ">
|
||||
<div id={id} ref={termRef} className="rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useState } from "react";
|
||||
|
||||
export const ToggleAutoCheckUpdates = ({ disabled }: { disabled: boolean }) => {
|
||||
const [enabled, setEnabled] = useState<boolean>(
|
||||
localStorage.getItem("enableAutoCheckUpdates") === "true",
|
||||
);
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
setEnabled(checked);
|
||||
localStorage.setItem("enableAutoCheckUpdates", String(checked));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
id="autoCheckUpdatesToggle"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label className="text-primary" htmlFor="autoCheckUpdatesToggle">
|
||||
Automatically check for new updates
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,91 +3,224 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import {
|
||||
Bug,
|
||||
Download,
|
||||
Info,
|
||||
RefreshCcw,
|
||||
Server,
|
||||
ServerCrash,
|
||||
Sparkles,
|
||||
Stars,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates";
|
||||
import { UpdateWebServer } from "./update-webserver";
|
||||
|
||||
export const UpdateServer = () => {
|
||||
const [isUpdateAvailable, setIsUpdateAvailable] = useState<null | boolean>(
|
||||
null,
|
||||
);
|
||||
const { mutateAsync: checkAndUpdateImage, isLoading } =
|
||||
api.settings.checkAndUpdateImage.useMutation();
|
||||
const [hasCheckedUpdate, setHasCheckedUpdate] = useState(false);
|
||||
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
|
||||
const { mutateAsync: getUpdateData, isLoading } =
|
||||
api.settings.getUpdateData.useMutation();
|
||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||
const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
|
||||
const handleCheckUpdates = async () => {
|
||||
try {
|
||||
const updateData = await getUpdateData();
|
||||
const versionToUpdate = updateData.latestVersion || "";
|
||||
setHasCheckedUpdate(true);
|
||||
setIsUpdateAvailable(updateData.updateAvailable);
|
||||
setLatestVersion(versionToUpdate);
|
||||
|
||||
if (updateData.updateAvailable) {
|
||||
toast.success(versionToUpdate, {
|
||||
description: "New version available!",
|
||||
});
|
||||
} else {
|
||||
toast.info("No updates available");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking for updates:", error);
|
||||
setHasCheckedUpdate(true);
|
||||
setIsUpdateAvailable(false);
|
||||
toast.error(
|
||||
"An error occurred while checking for updates, please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
<Button variant="secondary" className="gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Updates
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:m:max-w-lg ">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Web Server Update</DialogTitle>
|
||||
<DialogDescription>
|
||||
Check new releases and update your dokploy
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-lg p-6">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<DialogTitle className="text-2xl font-semibold">
|
||||
Web Server Update
|
||||
</DialogTitle>
|
||||
{dokployVersion && (
|
||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{dokployVersion} | {releaseTag}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
We suggest to update your dokploy to the latest version only if you:
|
||||
</span>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground">
|
||||
<li>Want to try the latest features</li>
|
||||
<li>Some bug that is blocking to use some features</li>
|
||||
</ul>
|
||||
<AlertBlock type="info">
|
||||
We recommend checking the latest version for any breaking changes
|
||||
before updating. Go to{" "}
|
||||
<Link
|
||||
href="https://github.com/Dokploy/dokploy/releases"
|
||||
target="_blank"
|
||||
className="text-foreground"
|
||||
>
|
||||
Dokploy Releases
|
||||
</Link>{" "}
|
||||
to check the latest version.
|
||||
</AlertBlock>
|
||||
{/* Initial state */}
|
||||
{!hasCheckedUpdate && (
|
||||
<div className="mb-8">
|
||||
<p className="text text-muted-foreground">
|
||||
Check for new releases and update Dokploy.
|
||||
<br />
|
||||
<br />
|
||||
We recommend checking for updates regularly to ensure you have the
|
||||
latest features and security improvements.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
{isUpdateAvailable === false && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<RefreshCcw className="size-6 self-center text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You are using the latest version
|
||||
{/* Update available state */}
|
||||
{isUpdateAvailable && latestVersion && (
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-lg px-3 py-2 border border-emerald-900 bg-emerald-900 dark:bg-emerald-900/40 mb-4 w-full">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-emerald-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
|
||||
</span>
|
||||
<Download className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text font-medium text-emerald-400 ">
|
||||
New version available:
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text font-semibold text-emerald-300">
|
||||
{latestVersion}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-muted-foreground">
|
||||
<p className="text">
|
||||
A new version of the server software is available. Consider
|
||||
updating if you:
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-start gap-2">
|
||||
<Stars className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
|
||||
<span className="text">
|
||||
Want to access the latest features and improvements
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Bug className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
|
||||
<span className="text">
|
||||
Are experiencing issues that may be resolved in the new
|
||||
version
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Up to date state */}
|
||||
{hasCheckedUpdate && !isUpdateAvailable && !isLoading && (
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col items-center gap-6 mb-6">
|
||||
<div className="rounded-full p-4 bg-emerald-400/40">
|
||||
<Sparkles className="h-8 w-8 text-emerald-400" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-medium">
|
||||
You are using the latest version
|
||||
</h3>
|
||||
<p className="text text-muted-foreground">
|
||||
Your server is up to date with all the latest features and
|
||||
security improvements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasCheckedUpdate && isLoading && (
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col items-center gap-6 mb-6">
|
||||
<div className="rounded-full p-4 bg-[#5B9DFF]/40 text-foreground">
|
||||
<RefreshCcw className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-medium">Checking for updates...</h3>
|
||||
<p className="text text-muted-foreground">
|
||||
Please wait while we pull the latest version information from
|
||||
Docker Hub.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUpdateAvailable && (
|
||||
<div className="rounded-lg bg-[#16254D] p-4 mb-8">
|
||||
<div className="flex gap-2">
|
||||
<Info className="h-5 w-5 flex-shrink-0 text-[#5B9DFF]" />
|
||||
<div className="text-[#5B9DFF]">
|
||||
We recommend reviewing the{" "}
|
||||
<Link
|
||||
href="https://github.com/Dokploy/dokploy/releases"
|
||||
target="_blank"
|
||||
className="text-white underline hover:text-zinc-200"
|
||||
>
|
||||
release notes
|
||||
</Link>{" "}
|
||||
for any breaking changes before updating.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<ToggleAutoCheckUpdates disabled={isLoading} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 flex items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{isUpdateAvailable ? (
|
||||
<UpdateWebServer />
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
await checkAndUpdateImage()
|
||||
.then(async (e) => {
|
||||
setIsUpdateAvailable(e);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsUpdateAvailable(false);
|
||||
toast.error("Error to check updates");
|
||||
});
|
||||
toast.success("Check updates");
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
variant="secondary"
|
||||
onClick={handleCheckUpdates}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Check Updates
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||
Checking for updates
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Check for updates
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -96,3 +229,5 @@ export const UpdateServer = () => {
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateServer;
|
||||
|
||||
@@ -11,24 +11,53 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { HardDriveDownload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const UpdateWebServer = () => {
|
||||
interface Props {
|
||||
isNavbar?: boolean;
|
||||
}
|
||||
|
||||
export const UpdateWebServer = ({ isNavbar }: Props) => {
|
||||
const { mutateAsync: updateServer, isLoading } =
|
||||
api.settings.updateServer.useMutation();
|
||||
|
||||
const buttonLabel = isNavbar ? "Update available" : "Update Server";
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
await updateServer();
|
||||
toast.success(
|
||||
"The server has been updated. The page will be reloaded to reflect the changes...",
|
||||
);
|
||||
setTimeout(() => {
|
||||
// Allow seeing the toast before reloading
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error("Error updating server:", error);
|
||||
toast.error(
|
||||
"An error occurred while updating the server, please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
className="relative w-full"
|
||||
variant="secondary"
|
||||
variant={isNavbar ? "outline" : "secondary"}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<span className="absolute -right-1 -top-2 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
|
||||
</span>
|
||||
Update Server
|
||||
{!isLoading && <HardDriveDownload className="h-4 w-4" />}
|
||||
{!isLoading && (
|
||||
<span className="absolute -right-1 -top-2 flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
|
||||
</span>
|
||||
)}
|
||||
{isLoading ? "Updating..." : buttonLabel}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
@@ -36,19 +65,12 @@ export const UpdateWebServer = () => {
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will update the web server to the
|
||||
new version.
|
||||
new version. The page will be reloaded once the update is finished.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await updateServer();
|
||||
toast.success("Please reload the browser to see the changes");
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
<AlertDialogAction onClick={handleConfirm}>Confirm</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import Head from "next/head";
|
||||
import { Navbar } from "./navbar";
|
||||
import { NavigationTabs, type TabState } from "./navigation-tabs";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
tab: TabState;
|
||||
metaName?: string;
|
||||
}
|
||||
|
||||
export const DashboardLayout = ({ children, tab }: Props) => {
|
||||
export const DashboardLayout = ({ children, tab, metaName }: Props) => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="bg-radial relative flex flex-col bg-background min-h-screen w-full"
|
||||
id="app-container"
|
||||
>
|
||||
<Navbar />
|
||||
<main className="pt-6 flex w-full flex-col items-center">
|
||||
<div className="w-full max-w-8xl px-4 lg:px-8">
|
||||
<NavigationTabs tab={tab}>{children}</NavigationTabs>
|
||||
</div>
|
||||
</main>
|
||||
<>
|
||||
<Head>
|
||||
<title>
|
||||
{metaName ?? tab.charAt(0).toUpperCase() + tab.slice(1)} | Dokploy
|
||||
</title>
|
||||
</Head>
|
||||
<div>
|
||||
<div
|
||||
className="bg-radial relative flex flex-col bg-background min-h-screen w-full"
|
||||
id="app-container"
|
||||
>
|
||||
<Navbar />
|
||||
<main className="pt-6 flex w-full flex-col items-center">
|
||||
<div className="w-full max-w-8xl px-4 lg:px-8">
|
||||
<NavigationTabs tab={tab}>{children}</NavigationTabs>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,11 +12,16 @@ import { api } from "@/utils/api";
|
||||
import { HeartIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { UpdateWebServer } from "../dashboard/settings/web-server/update-webserver";
|
||||
import { Logo } from "../shared/logo";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
import { buttonVariants } from "../ui/button";
|
||||
|
||||
const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
|
||||
|
||||
export const Navbar = () => {
|
||||
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
const { data } = api.auth.get.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
@@ -29,6 +34,59 @@ export const Navbar = () => {
|
||||
},
|
||||
);
|
||||
const { mutateAsync } = api.auth.logout.useMutation();
|
||||
const { mutateAsync: getUpdateData } =
|
||||
api.settings.getUpdateData.useMutation();
|
||||
|
||||
const checkUpdatesIntervalRef = useRef<null | NodeJS.Timeout>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Handling of automatic check for server updates
|
||||
if (isCloud) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!localStorage.getItem("enableAutoCheckUpdates")) {
|
||||
// Enable auto update checking by default if user didn't change it
|
||||
localStorage.setItem("enableAutoCheckUpdates", "true");
|
||||
}
|
||||
|
||||
const clearUpdatesInterval = () => {
|
||||
if (checkUpdatesIntervalRef.current) {
|
||||
clearInterval(checkUpdatesIntervalRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const checkUpdates = async () => {
|
||||
try {
|
||||
if (localStorage.getItem("enableAutoCheckUpdates") !== "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
const { updateAvailable } = await getUpdateData();
|
||||
|
||||
if (updateAvailable) {
|
||||
// Stop interval when update is available
|
||||
clearUpdatesInterval();
|
||||
setIsUpdateAvailable(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error auto-checking for updates:", error);
|
||||
}
|
||||
};
|
||||
|
||||
checkUpdatesIntervalRef.current = setInterval(
|
||||
checkUpdates,
|
||||
AUTO_CHECK_UPDATES_INTERVAL_MINUTES * 60000,
|
||||
);
|
||||
|
||||
// Also check for updates on initial page load
|
||||
checkUpdates();
|
||||
|
||||
return () => {
|
||||
clearUpdatesInterval();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="border-divider sticky inset-x-0 top-0 z-40 flex h-auto w-full items-center justify-center border-b bg-background/70 backdrop-blur-lg backdrop-saturate-150 data-[menu-open=true]:border-none data-[menu-open=true]:backdrop-blur-xl">
|
||||
<header className="relative z-40 flex w-full max-w-8xl flex-row flex-nowrap items-center justify-between gap-4 px-4 sm:px-6 h-16">
|
||||
@@ -43,6 +101,11 @@ export const Navbar = () => {
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
{isUpdateAvailable && (
|
||||
<div>
|
||||
<UpdateWebServer isNavbar />
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
|
||||
@@ -77,7 +77,7 @@ export const SettingsLayout = ({ children }: Props) => {
|
||||
{
|
||||
title: "Registry",
|
||||
label: "",
|
||||
icon: ListMusic,
|
||||
icon: GalleryVerticalEnd,
|
||||
href: "/dashboard/settings/registry",
|
||||
},
|
||||
|
||||
@@ -150,6 +150,7 @@ import {
|
||||
BoxesIcon,
|
||||
CreditCardIcon,
|
||||
Database,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
KeyIcon,
|
||||
KeyRound,
|
||||
|
||||
@@ -2,7 +2,9 @@ import { cn } from "@/lib/utils";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { yaml } from "@codemirror/lang-yaml";
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
|
||||
import { properties } from "@codemirror/legacy-modes/mode/properties";
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
|
||||
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
@@ -10,14 +12,16 @@ import { useTheme } from "next-themes";
|
||||
interface Props extends ReactCodeMirrorProps {
|
||||
wrapperClassName?: string;
|
||||
disabled?: boolean;
|
||||
language?: "yaml" | "json" | "properties";
|
||||
language?: "yaml" | "json" | "properties" | "shell";
|
||||
lineWrapping?: boolean;
|
||||
lineNumbers?: boolean;
|
||||
}
|
||||
|
||||
export const CodeEditor = ({
|
||||
className,
|
||||
wrapperClassName,
|
||||
language = "yaml",
|
||||
lineNumbers = true,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
@@ -25,7 +29,7 @@ export const CodeEditor = ({
|
||||
<div className={cn("relative overflow-auto", wrapperClassName)}>
|
||||
<CodeMirror
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
lineNumbers,
|
||||
foldGutter: true,
|
||||
highlightSelectionMatches: true,
|
||||
highlightActiveLine: !props.disabled,
|
||||
@@ -37,7 +41,9 @@ export const CodeEditor = ({
|
||||
? yaml()
|
||||
: language === "json"
|
||||
? json()
|
||||
: StreamLanguage.define(properties),
|
||||
: language === "shell"
|
||||
? StreamLanguage.define(shell)
|
||||
: StreamLanguage.define(properties),
|
||||
props.lineWrapping ? EditorView.lineWrapping : [],
|
||||
]}
|
||||
{...props}
|
||||
|
||||
@@ -14,6 +14,16 @@ const badgeVariants = cva(
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
red: "border-transparent select-none items-center whitespace-nowrap font-medium bg-red-600/20 dark:bg-red-500/15 text-destructive text-xs h-4 px-1 py-1 rounded-md",
|
||||
yellow:
|
||||
"border-transparent select-none items-center whitespace-nowrap font-medium bg-yellow-600/20 dark:bg-yellow-500/15 dark:text-yellow-500 text-yellow-600 text-xs h-4 px-1 py-1 rounded-md",
|
||||
orange:
|
||||
"border-transparent select-none items-center whitespace-nowrap font-medium bg-orange-600/20 dark:bg-orange-500/15 dark:text-orange-500 text-orange-600 text-xs h-4 px-1 py-1 rounded-md",
|
||||
green:
|
||||
"border-transparent select-none items-center whitespace-nowrap font-medium bg-emerald-600/20 dark:bg-emerald-500/15 dark:text-emerald-500 text-emerald-600 text-xs h-4 px-1 py-1 rounded-md",
|
||||
blue: "border-transparent select-none items-center whitespace-nowrap font-medium bg-blue-600/20 dark:bg-blue-500/15 dark:text-blue-500 text-blue-600 text-xs h-4 px-1 py-1 rounded-md",
|
||||
blank:
|
||||
"border-transparent select-none items-center whitespace-nowrap font-medium dark:bg-white/15 bg-black/15 text-foreground text-xs h-4 px-1 py-1 rounded-md",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ export const Secrets = (props: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between">
|
||||
<CardHeader className="flex flex-row w-full items-center justify-between px-0">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{props.title}</CardTitle>
|
||||
<CardDescription>{props.description}</CardDescription>
|
||||
@@ -47,7 +47,7 @@ export const Secrets = (props: Props) => {
|
||||
)}
|
||||
</Toggle>
|
||||
</CardHeader>
|
||||
<CardContent className="w-full space-y-4">
|
||||
<CardContent className="w-full space-y-4 p-0">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={props.name}
|
||||
@@ -62,6 +62,7 @@ export const Secrets = (props: Props) => {
|
||||
}
|
||||
language="properties"
|
||||
disabled={isVisible}
|
||||
lineWrapping
|
||||
placeholder={props.placeholder}
|
||||
className="h-96 font-mono"
|
||||
{...field}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -9,7 +11,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -17,7 +19,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -9,6 +9,8 @@ const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipPortal = TooltipPrimitive.Portal;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
@@ -25,4 +27,10 @@ const TooltipContent = React.forwardRef<
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
export {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipPortal,
|
||||
};
|
||||
|
||||
1
apps/dokploy/drizzle/0047_tidy_revanche.sql
Normal file
1
apps/dokploy/drizzle/0047_tidy_revanche.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ADD COLUMN "herokuVersion" text;
|
||||
1
apps/dokploy/drizzle/0048_flat_expediter.sql
Normal file
1
apps/dokploy/drizzle/0048_flat_expediter.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ALTER COLUMN "herokuVersion" SET DEFAULT '24';
|
||||
53
apps/dokploy/drizzle/0049_dark_leopardon.sql
Normal file
53
apps/dokploy/drizzle/0049_dark_leopardon.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
ALTER TYPE "domainType" ADD VALUE 'preview';--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "preview_deployments" (
|
||||
"previewDeploymentId" text PRIMARY KEY NOT NULL,
|
||||
"branch" text NOT NULL,
|
||||
"pullRequestId" text NOT NULL,
|
||||
"pullRequestNumber" text NOT NULL,
|
||||
"pullRequestURL" text NOT NULL,
|
||||
"pullRequestTitle" text NOT NULL,
|
||||
"pullRequestCommentId" text NOT NULL,
|
||||
"previewStatus" "applicationStatus" DEFAULT 'idle' NOT NULL,
|
||||
"appName" text NOT NULL,
|
||||
"applicationId" text NOT NULL,
|
||||
"domainId" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"expiresAt" text,
|
||||
CONSTRAINT "preview_deployments_appName_unique" UNIQUE("appName")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewEnv" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewBuildArgs" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewWildcard" text;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewPort" integer DEFAULT 3000;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewHttps" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewPath" text DEFAULT '/';--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "certificateType" "certificateType" DEFAULT 'none' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "previewLimit" integer DEFAULT 3;--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "isPreviewDeploymentsActive" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "domain" ADD COLUMN "previewDeploymentId" text;--> statement-breakpoint
|
||||
ALTER TABLE "deployment" ADD COLUMN "isPreviewDeployment" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "deployment" ADD COLUMN "previewDeploymentId" text;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "preview_deployments" ADD CONSTRAINT "preview_deployments_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "preview_deployments" ADD CONSTRAINT "preview_deployments_domainId_domain_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domain"("domainId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "domain" ADD CONSTRAINT "domain_previewDeploymentId_preview_deployments_previewDeploymentId_fk" FOREIGN KEY ("previewDeploymentId") REFERENCES "public"."preview_deployments"("previewDeploymentId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_previewDeploymentId_preview_deployments_previewDeploymentId_fk" FOREIGN KEY ("previewDeploymentId") REFERENCES "public"."preview_deployments"("previewDeploymentId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
1
apps/dokploy/drizzle/0050_nappy_wrecker.sql
Normal file
1
apps/dokploy/drizzle/0050_nappy_wrecker.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "gitlab" ADD COLUMN "gitlabUrl" text DEFAULT 'https://gitlab.com' NOT NULL;
|
||||
1
apps/dokploy/drizzle/0051_hard_gorgon.sql
Normal file
1
apps/dokploy/drizzle/0051_hard_gorgon.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "server" ADD COLUMN "command" text DEFAULT '' NOT NULL;
|
||||
3993
apps/dokploy/drizzle/meta/0047_snapshot.json
Normal file
3993
apps/dokploy/drizzle/meta/0047_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3994
apps/dokploy/drizzle/meta/0048_snapshot.json
Normal file
3994
apps/dokploy/drizzle/meta/0048_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user