mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Compare commits
290 Commits
migration/
...
v0.15.1
| 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 | ||
|
|
49559ebee6 | ||
|
|
f94ee8c299 | ||
|
|
f47335efe5 | ||
|
|
572579af91 | ||
|
|
b296b6bbf0 | ||
|
|
5c8721406a | ||
|
|
be934065d9 | ||
|
|
8162dcfb71 | ||
|
|
46f7d43595 | ||
|
|
59bb59ee24 | ||
|
|
0e433a3d36 | ||
|
|
819de5a32e | ||
|
|
5cd624c7ea | ||
|
|
89c7e96df0 | ||
|
|
0e136ffb8f | ||
|
|
6b4e52fb37 | ||
|
|
db9136d981 | ||
|
|
a93f18eb4a | ||
|
|
467acc4d4d | ||
|
|
01e5cf0852 | ||
|
|
df9fad088f | ||
|
|
2644b638d1 | ||
|
|
acd722678e | ||
|
|
727e50648e | ||
|
|
349bc89851 | ||
|
|
9f6f872536 | ||
|
|
e378d89477 | ||
|
|
63e7eacae9 | ||
|
|
f4ab588516 | ||
|
|
4d8a0ba58f | ||
|
|
e88cd11041 | ||
|
|
5f174a883b | ||
|
|
536a6ba2ff | ||
|
|
213fa08210 | ||
|
|
d5c6a601d8 | ||
|
|
452793c8e5 | ||
|
|
385fbf4af5 | ||
|
|
3590f3bed2 | ||
|
|
9b2fcaea31 | ||
|
|
5abcc82215 | ||
|
|
ee855452e3 | ||
|
|
d000b526d3 | ||
|
|
9bf88b90c3 | ||
|
|
b1a48d4636 | ||
|
|
c34c4b244e | ||
|
|
bb59a0cd3f | ||
|
|
44e6a117dd | ||
|
|
bfdc73f8d1 | ||
|
|
64ada7020a | ||
|
|
4706adc0c0 | ||
|
|
e01d92d1d9 | ||
|
|
fe22890311 | ||
|
|
2b7c7632f4 | ||
|
|
1b7244e841 |
@@ -99,14 +99,14 @@ workflows:
|
|||||||
only:
|
only:
|
||||||
- main
|
- main
|
||||||
- canary
|
- canary
|
||||||
- 379-preview-deployment
|
- fix/nixpacks-version
|
||||||
- build-arm64:
|
- build-arm64:
|
||||||
filters:
|
filters:
|
||||||
branches:
|
branches:
|
||||||
only:
|
only:
|
||||||
- main
|
- main
|
||||||
- canary
|
- canary
|
||||||
- 379-preview-deployment
|
- fix/nixpacks-version
|
||||||
- combine-manifests:
|
- combine-manifests:
|
||||||
requires:
|
requires:
|
||||||
- build-amd64
|
- build-amd64
|
||||||
@@ -116,4 +116,4 @@ workflows:
|
|||||||
only:
|
only:
|
||||||
- main
|
- main
|
||||||
- canary
|
- canary
|
||||||
- 379-preview-deployment
|
- fix/nixpacks-version
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,7 +34,6 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# Editor
|
# Editor
|
||||||
.vscode
|
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
|
|||||||
|
|
||||||
# Install Nixpacks and tsx
|
# Install Nixpacks and tsx
|
||||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||||
|
|
||||||
|
ARG NIXPACKS_VERSION=1.29.1
|
||||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||||
&& chmod +x install.sh \
|
&& chmod +x install.sh \
|
||||||
&& ./install.sh \
|
&& ./install.sh \
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const Login2FA = ({ authId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardTitle className="text-xl font-bold">2FA Setup</CardTitle>
|
<CardTitle className="text-xl font-bold">2FA Login</CardTitle>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -102,9 +103,26 @@ export const DeleteApplication = ({ applicationId }: Props) => {
|
|||||||
name="projectName"
|
name="projectName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel className="flex items-center gap-2">
|
||||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
<span>
|
||||||
below
|
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>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useEffect, useRef, useState } from "react";
|
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 {
|
interface Props {
|
||||||
logPath: string | null;
|
logPath: string | null;
|
||||||
@@ -15,9 +19,26 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
|
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
|
||||||
const [data, setData] = useState("");
|
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 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(() => {
|
useEffect(() => {
|
||||||
if (!open || !logPath) return;
|
if (!open || !logPath) return;
|
||||||
|
|
||||||
@@ -48,13 +69,20 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
|
|||||||
};
|
};
|
||||||
}, [logPath, open]);
|
}, [logPath, open]);
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
|
useEffect(() => {
|
||||||
};
|
const logs = parseLogs(data);
|
||||||
|
setFilteredLogs(logs);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [data]);
|
|
||||||
|
if (autoScroll && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [filteredLogs, autoScroll]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -76,17 +104,27 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Deployment</DialogTitle>
|
<DialogTitle>Deployment</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
|
<div
|
||||||
<code>
|
ref={scrollRef}
|
||||||
<pre className="whitespace-pre-wrap break-words">
|
onScroll={handleScroll}
|
||||||
{data || "Loading..."}
|
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||||
</pre>
|
> {
|
||||||
<div ref={endOfLogsRef} />
|
filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => (
|
||||||
</code>
|
<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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,6 +19,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DeployApplication = ({ applicationId }: Props) => {
|
export const DeployApplication = ({ applicationId }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
const { data, refetch } = api.application.one.useQuery(
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -51,6 +53,9 @@ export const DeployApplication = ({ applicationId }: Props) => {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Application deployed succesfully");
|
toast.success("Application deployed succesfully");
|
||||||
await refetch();
|
await refetch();
|
||||||
|
router.push(
|
||||||
|
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
|
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
</Select>
|
</Select>
|
||||||
<DockerLogs
|
<DockerLogs
|
||||||
serverId={serverId || ""}
|
serverId={serverId || ""}
|
||||||
id="terminal"
|
|
||||||
containerId={containerId || "select-a-container"}
|
containerId={containerId || "select-a-container"}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -100,10 +102,27 @@ export const DeleteCompose = ({ composeId }: Props) => {
|
|||||||
name="projectName"
|
name="projectName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel className="flex items-center gap-2">
|
||||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
<span>
|
||||||
below
|
To confirm, type{" "}
|
||||||
</FormLabel>{" "}
|
<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>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter compose name to confirm"
|
placeholder="Enter compose name to confirm"
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useEffect, useRef, useState } from "react";
|
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 {
|
interface Props {
|
||||||
logPath: string | null;
|
logPath: string | null;
|
||||||
@@ -20,9 +25,26 @@ export const ShowDeploymentCompose = ({
|
|||||||
serverId,
|
serverId,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [data, setData] = useState("");
|
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 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(() => {
|
useEffect(() => {
|
||||||
if (!open || !logPath) return;
|
if (!open || !logPath) return;
|
||||||
|
|
||||||
@@ -54,13 +76,19 @@ export const ShowDeploymentCompose = ({
|
|||||||
};
|
};
|
||||||
}, [logPath, open]);
|
}, [logPath, open]);
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
|
||||||
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
|
useEffect(() => {
|
||||||
};
|
const logs = parseLogs(data);
|
||||||
|
setFilteredLogs(logs);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [data]);
|
|
||||||
|
if (autoScroll && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [filteredLogs, autoScroll]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>Deployment</DialogTitle>
|
<DialogTitle>Deployment</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
|
<div
|
||||||
<code>
|
ref={scrollRef}
|
||||||
<pre className="whitespace-pre-wrap break-words">
|
onScroll={handleScroll}
|
||||||
{data || "Loading..."}
|
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||||
</pre>
|
>
|
||||||
<div ref={endOfLogsRef} />
|
|
||||||
</code>
|
|
||||||
|
{
|
||||||
|
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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,6 +19,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DeployCompose = ({ composeId }: Props) => {
|
export const DeployCompose = ({ composeId }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
const { data, refetch } = api.compose.one.useQuery(
|
const { data, refetch } = api.compose.one.useQuery(
|
||||||
{
|
{
|
||||||
composeId,
|
composeId,
|
||||||
@@ -48,9 +50,15 @@ export const DeployCompose = ({ composeId }: Props) => {
|
|||||||
await refetch();
|
await refetch();
|
||||||
await deploy({
|
await deploy({
|
||||||
composeId,
|
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();
|
await refetch();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { ImportIcon, SquarePen } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const updateComposeSchema = z.object({
|
|
||||||
name: z.string().min(1, {
|
|
||||||
message: "Name is required",
|
|
||||||
}),
|
|
||||||
description: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type UpdateCompose = z.infer<typeof updateComposeSchema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ImportTemplate = ({ composeId }: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { mutateAsync, error, isError, isLoading } =
|
|
||||||
api.compose.update.useMutation();
|
|
||||||
const { data } = api.compose.one.useQuery(
|
|
||||||
{
|
|
||||||
composeId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!composeId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const form = useForm<UpdateCompose>({
|
|
||||||
defaultValues: {
|
|
||||||
description: data?.description ?? "",
|
|
||||||
name: data?.name ?? "",
|
|
||||||
},
|
|
||||||
resolver: zodResolver(updateComposeSchema),
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
form.reset({
|
|
||||||
description: data.description ?? "",
|
|
||||||
name: data.name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [data, form, form.reset]);
|
|
||||||
|
|
||||||
const onSubmit = async (formData: UpdateCompose) => {
|
|
||||||
await mutateAsync({
|
|
||||||
name: formData.name,
|
|
||||||
composeId: composeId,
|
|
||||||
description: formData.description || "",
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Compose updated succesfully");
|
|
||||||
utils.compose.one.invalidate({
|
|
||||||
composeId: composeId,
|
|
||||||
});
|
|
||||||
setIsOpen(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error to update the Compose");
|
|
||||||
})
|
|
||||||
.finally(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button variant="ghost">
|
|
||||||
<ImportIcon className="size-5 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Import Template</DialogTitle>
|
|
||||||
<DialogDescription>Import external template</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
<div className="grid items-center gap-4">
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
id="hook-form-update-compose"
|
|
||||||
className="grid w-full gap-4 "
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Name</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder="Tesla" {...field} />
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Description</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Description about your project..."
|
|
||||||
className="resize-none"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={isLoading}
|
|
||||||
form="hook-form-update-compose"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -96,7 +96,6 @@ export const ShowDockerLogsCompose = ({
|
|||||||
</Select>
|
</Select>
|
||||||
<DockerLogs
|
<DockerLogs
|
||||||
serverId={serverId || ""}
|
serverId={serverId || ""}
|
||||||
id="terminal"
|
|
||||||
containerId={containerId || "select-a-container"}
|
containerId={containerId || "select-a-container"}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -34,7 +35,7 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
|
|||||||
View Config
|
View Config
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className={"w-full md:w-[70vw] max-w-max"}>
|
<DialogContent className={"w-full md:w-[70vw] min-w-[70vw]"}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Container Config</DialogTitle>
|
<DialogTitle>Container Config</DialogTitle>
|
||||||
<DialogDescription>
|
<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]">
|
<div className="text-wrap rounded-lg border p-4 text-sm bg-card overflow-y-auto max-h-[80vh]">
|
||||||
<code>
|
<code>
|
||||||
<pre className="whitespace-pre-wrap break-words">
|
<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>
|
</pre>
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,114 +1,290 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { api } from "@/utils/api";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Download as DownloadIcon, Loader2 } from "lucide-react";
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { FitAddon } from "xterm-addon-fit";
|
import { LineCountFilter } from "./line-count-filter";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
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 {
|
interface Props {
|
||||||
id: string;
|
|
||||||
containerId: string;
|
containerId: string;
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DockerLogsId: React.FC<Props> = ({
|
export const priorities = [
|
||||||
id,
|
{
|
||||||
containerId,
|
label: "Info",
|
||||||
serverId,
|
value: "info",
|
||||||
}) => {
|
},
|
||||||
const [term, setTerm] = React.useState<Terminal>();
|
{
|
||||||
const [lines, setLines] = React.useState<number>(40);
|
label: "Success",
|
||||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
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(() => {
|
useEffect(() => {
|
||||||
// if (containerId === "select-a-container") {
|
if (!containerId) return;
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
const container = document.getElementById(id);
|
|
||||||
if (container) {
|
|
||||||
container.innerHTML = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wsRef.current) {
|
let isCurrentConnection = true;
|
||||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
let noDataTimeout: NodeJS.Timeout;
|
||||||
wsRef.current.close();
|
setIsLoading(true);
|
||||||
}
|
setRawLogs("");
|
||||||
wsRef.current = null;
|
setFilteredLogs([]);
|
||||||
}
|
|
||||||
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)",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
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);
|
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) => {
|
const resetNoDataTimeout = () => {
|
||||||
console.error("WebSocket error: ", error);
|
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) => {
|
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) => {
|
ws.onclose = (e) => {
|
||||||
console.log(e.reason);
|
if (!isCurrentConnection) return;
|
||||||
|
console.log("WebSocket closed:", e.reason);
|
||||||
termi.write(`Connection closed!\nReason: ${e.reason}\n`);
|
setIsLoading(false);
|
||||||
wsRef.current = null;
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
isCurrentConnection = false;
|
||||||
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.close();
|
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(() => {
|
useEffect(() => {
|
||||||
term?.clear();
|
setRawLogs("");
|
||||||
}, [lines, term]);
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="rounded-lg overflow-hidden">
|
||||||
<Label>
|
<div className="space-y-4">
|
||||||
<span>Number of lines to show</span>
|
<div className="flex flex-wrap justify-between items-start sm:items-center gap-4">
|
||||||
</Label>
|
<div className="flex flex-wrap gap-4">
|
||||||
<Input
|
<LineCountFilter value={lines} onValueChange={handleLines} />
|
||||||
type="text"
|
|
||||||
placeholder="Number of lines to show (Defaults to 35)"
|
|
||||||
value={lines}
|
|
||||||
onChange={(e) => {
|
|
||||||
setLines(Number(e.target.value) || 1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
|
<SinceLogsFilter
|
||||||
<div id={id} />
|
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>
|
||||||
</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>
|
<DialogDescription>View the logs for {containerId}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<DockerLogsId
|
<DockerLogsId containerId={containerId || ""} serverId={serverId} />
|
||||||
id="terminal"
|
|
||||||
containerId={containerId || ""}
|
|
||||||
serverId={serverId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { FitAddon } from "xterm-addon-fit";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { AttachAddon } from "@xterm/addon-attach";
|
import { AttachAddon } from "@xterm/addon-attach";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +19,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const termRef = useRef(null);
|
const termRef = useRef(null);
|
||||||
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = document.getElementById(id);
|
const container = document.getElementById(id);
|
||||||
if (container) {
|
if (container) {
|
||||||
@@ -25,13 +27,12 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cols: 80,
|
|
||||||
rows: 30,
|
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
theme: {
|
theme: {
|
||||||
cursor: "transparent",
|
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
|
||||||
background: "rgba(0, 0, 0, 0)",
|
background: "rgba(0, 0, 0, 0)",
|
||||||
|
foreground: "currentColor",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const addonFit = new FitAddon();
|
const addonFit = new FitAddon();
|
||||||
@@ -45,6 +46,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
const addonAttach = new AttachAddon(ws);
|
const addonAttach = new AttachAddon(ws);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
term.open(termRef.current);
|
term.open(termRef.current);
|
||||||
|
// @ts-ignore
|
||||||
term.loadAddon(addonFit);
|
term.loadAddon(addonFit);
|
||||||
term.loadAddon(addonAttach);
|
term.loadAddon(addonAttach);
|
||||||
addonFit.fit();
|
addonFit.fit();
|
||||||
@@ -66,7 +68,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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 id={id} ref={termRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -99,9 +100,26 @@ export const DeleteMariadb = ({ mariadbId }: Props) => {
|
|||||||
name="projectName"
|
name="projectName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel className="flex items-center gap-2">
|
||||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
<span>
|
||||||
below
|
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>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -98,9 +99,26 @@ export const DeleteMongo = ({ mongoId }: Props) => {
|
|||||||
name="projectName"
|
name="projectName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel className="flex items-center gap-2">
|
||||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
<span>
|
||||||
below
|
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>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -97,9 +98,26 @@ export const DeleteMysql = ({ mysqlId }: Props) => {
|
|||||||
name="projectName"
|
name="projectName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel className="flex items-center gap-2">
|
||||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
<span>
|
||||||
below
|
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>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -100,9 +101,26 @@ export const DeletePostgres = ({ postgresId }: Props) => {
|
|||||||
name="projectName"
|
name="projectName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel className="flex items-center gap-2">
|
||||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
<span>
|
||||||
below
|
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>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
template.tags.some((tag) => selectedTags.includes(tag));
|
template.tags.some((tag) => selectedTags.includes(tag));
|
||||||
const matchesQuery =
|
const matchesQuery =
|
||||||
query === "" ||
|
query === "" ||
|
||||||
template.name.toLowerCase().includes(query.toLowerCase());
|
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
template.description.toLowerCase().includes(query.toLowerCase());
|
||||||
return matchesTags && matchesQuery;
|
return matchesTags && matchesQuery;
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
AlertDialogContent,
|
AlertDialogContent,
|
||||||
AlertDialogDescription,
|
AlertDialogDescription,
|
||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
BookIcon,
|
BookIcon,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
@@ -38,253 +38,257 @@ import { ProjectEnviroment } from "./project-enviroment";
|
|||||||
import { UpdateProject } from "./update";
|
import { UpdateProject } from "./update";
|
||||||
|
|
||||||
export const ShowProjects = () => {
|
export const ShowProjects = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data } = api.project.all.useQuery();
|
const { data } = api.project.all.useQuery();
|
||||||
const { data: auth } = api.auth.get.useQuery();
|
const { data: auth } = api.auth.get.useQuery();
|
||||||
const { data: user } = api.user.byAuthId.useQuery(
|
const { data: user } = api.user.byAuthId.useQuery(
|
||||||
{
|
{
|
||||||
authId: auth?.id || "",
|
authId: auth?.id || "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!auth?.id && auth?.rol === "user",
|
enabled: !!auth?.id && auth?.rol === "user",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
const { mutateAsync } = api.project.remove.useMutation();
|
const { mutateAsync } = api.project.remove.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{data?.length === 0 && (
|
{data?.length === 0 && (
|
||||||
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
|
<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" />
|
<FolderInput className="size-10 md:size-28 text-muted-foreground" />
|
||||||
<span className="text-center font-medium text-muted-foreground">
|
<span className="text-center font-medium text-muted-foreground">
|
||||||
No projects added yet. Click on Create project.
|
No projects added yet. Click on Create project.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
|
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
|
||||||
{data?.map((project) => {
|
{data?.map((project) => {
|
||||||
const emptyServices =
|
const emptyServices =
|
||||||
project?.mariadb.length === 0 &&
|
project?.mariadb.length === 0 &&
|
||||||
project?.mongo.length === 0 &&
|
project?.mongo.length === 0 &&
|
||||||
project?.mysql.length === 0 &&
|
project?.mysql.length === 0 &&
|
||||||
project?.postgres.length === 0 &&
|
project?.postgres.length === 0 &&
|
||||||
project?.redis.length === 0 &&
|
project?.redis.length === 0 &&
|
||||||
project?.applications.length === 0 &&
|
project?.applications.length === 0 &&
|
||||||
project?.compose.length === 0;
|
project?.compose.length === 0;
|
||||||
|
|
||||||
const totalServices =
|
const totalServices =
|
||||||
project?.mariadb.length +
|
project?.mariadb.length +
|
||||||
project?.mongo.length +
|
project?.mongo.length +
|
||||||
project?.mysql.length +
|
project?.mysql.length +
|
||||||
project?.postgres.length +
|
project?.postgres.length +
|
||||||
project?.redis.length +
|
project?.redis.length +
|
||||||
project?.applications.length +
|
project?.applications.length +
|
||||||
project?.compose.length;
|
project?.compose.length;
|
||||||
|
|
||||||
const flattedDomains = [
|
const flattedDomains = [
|
||||||
...project.applications.flatMap((a) => a.domains),
|
...project.applications.flatMap((a) => a.domains),
|
||||||
...project.compose.flatMap((a) => a.domains),
|
...project.compose.flatMap((a) => a.domains),
|
||||||
];
|
];
|
||||||
|
|
||||||
const renderDomainsDropdown = (
|
const renderDomainsDropdown = (
|
||||||
item: typeof project.compose | typeof project.applications,
|
item: typeof project.compose | typeof project.applications
|
||||||
) =>
|
) =>
|
||||||
item[0] ? (
|
item[0] ? (
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel>
|
<DropdownMenuLabel>
|
||||||
{"applicationId" in item[0] ? "Applications" : "Compose"}
|
{"applicationId" in item[0] ? "Applications" : "Compose"}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{item.map((a) => (
|
{item.map((a) => (
|
||||||
<Fragment
|
<Fragment
|
||||||
key={"applicationId" in a ? a.applicationId : a.composeId}
|
key={"applicationId" in a ? a.applicationId : a.composeId}
|
||||||
>
|
>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuLabel className="font-normal capitalize text-xs ">
|
<DropdownMenuLabel className="font-normal capitalize text-xs ">
|
||||||
{a.name}
|
{a.name}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{a.domains.map((domain) => (
|
{a.domains.map((domain) => (
|
||||||
<DropdownMenuItem key={domain.domainId} asChild>
|
<DropdownMenuItem key={domain.domainId} asChild>
|
||||||
<Link
|
<Link
|
||||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
href={`${domain.https ? "https" : "http"}://${
|
||||||
>
|
domain.host
|
||||||
<span>{domain.host}</span>
|
}${domain.path}`}
|
||||||
<ExternalLink className="size-4 shrink-0" />
|
>
|
||||||
</Link>
|
<span>{domain.host}</span>
|
||||||
</DropdownMenuItem>
|
<ExternalLink className="size-4 shrink-0" />
|
||||||
))}
|
</Link>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuItem>
|
||||||
</Fragment>
|
))}
|
||||||
))}
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuGroup>
|
</Fragment>
|
||||||
) : null;
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={project.projectId} className="w-full lg:max-w-md">
|
<div key={project.projectId} className="w-full lg:max-w-md">
|
||||||
<Link href={`/dashboard/project/${project.projectId}`}>
|
<Link href={`/dashboard/project/${project.projectId}`}>
|
||||||
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
|
<Card className="group relative w-full bg-transparent transition-colors hover:bg-card">
|
||||||
{flattedDomains.length > 1 ? (
|
{flattedDomains.length > 1 ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<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"
|
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"
|
size="sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="size-3.5" />
|
<ExternalLinkIcon className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
className="w-[200px] space-y-2"
|
className="w-[200px] space-y-2"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{renderDomainsDropdown(project.applications)}
|
{renderDomainsDropdown(project.applications)}
|
||||||
{renderDomainsDropdown(project.compose)}
|
{renderDomainsDropdown(project.compose)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : flattedDomains[0] ? (
|
) : flattedDomains[0] ? (
|
||||||
<Button
|
<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"
|
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"
|
size="sm"
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`${flattedDomains[0].https ? "https" : "http"}://${flattedDomains[0].host}${flattedDomains[0].path}`}
|
href={`${
|
||||||
target="_blank"
|
flattedDomains[0].https ? "https" : "http"
|
||||||
>
|
}://${flattedDomains[0].host}${flattedDomains[0].path}`}
|
||||||
<ExternalLinkIcon className="size-3.5" />
|
target="_blank"
|
||||||
</Link>
|
>
|
||||||
</Button>
|
<ExternalLinkIcon className="size-3.5" />
|
||||||
) : null}
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between gap-2">
|
<CardTitle className="flex items-center justify-between gap-2">
|
||||||
<span className="flex flex-col gap-1.5">
|
<span className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookIcon className="size-4 text-muted-foreground" />
|
<BookIcon className="size-4 text-muted-foreground" />
|
||||||
<span className="text-base font-medium leading-none">
|
<span className="text-base font-medium leading-none">
|
||||||
{project.name}
|
{project.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
{project.description}
|
{project.description}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex self-start space-x-1">
|
<div className="flex self-start space-x-1">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="px-2"
|
className="px-2"
|
||||||
>
|
>
|
||||||
<MoreHorizontalIcon className="size-5" />
|
<MoreHorizontalIcon className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-[200px] space-y-2">
|
<DropdownMenuContent className="w-[200px] space-y-2">
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
Actions
|
Actions
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<ProjectEnviroment
|
<ProjectEnviroment
|
||||||
projectId={project.projectId}
|
projectId={project.projectId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<UpdateProject projectId={project.projectId} />
|
<UpdateProject projectId={project.projectId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
{(auth?.rol === "admin" ||
|
{(auth?.rol === "admin" ||
|
||||||
user?.canDeleteProjects) && (
|
user?.canDeleteProjects) && (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger className="w-full">
|
<AlertDialogTrigger className="w-full">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer space-x-3"
|
className="w-full cursor-pointer space-x-3"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<TrashIcon className="size-4" />
|
<TrashIcon className="size-4" />
|
||||||
<span>Delete</span>
|
<span>Delete</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>
|
<AlertDialogTitle>
|
||||||
Are you sure to delete this project?
|
Are you sure to delete this project?
|
||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
{!emptyServices ? (
|
{!emptyServices ? (
|
||||||
<div className="flex flex-row gap-4 rounded-lg bg-yellow-50 p-2 dark:bg-yellow-950">
|
<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" />
|
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
|
||||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||||
You have active services, please
|
You have active services, please
|
||||||
delete them first
|
delete them first
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone
|
This action cannot be undone
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
)}
|
)}
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>
|
<AlertDialogCancel>
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
disabled={!emptyServices}
|
disabled={!emptyServices}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
projectId: project.projectId,
|
projectId: project.projectId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(
|
toast.success(
|
||||||
"Project delete succesfully",
|
"Project delete succesfully"
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Error to delete this project",
|
"Error to delete this project"
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
utils.project.all.invalidate();
|
utils.project.all.invalidate();
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardFooter className="pt-4">
|
<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">
|
<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}>
|
<DateTooltip date={project.createdAt}>
|
||||||
Created
|
Created
|
||||||
</DateTooltip>
|
</DateTooltip>
|
||||||
<span>
|
<span>
|
||||||
{totalServices}{" "}
|
{totalServices}{" "}
|
||||||
{totalServices === 1 ? "service" : "services"}
|
{totalServices === 1 ? "service" : "services"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -97,9 +98,26 @@ export const DeleteRedis = ({ redisId }: Props) => {
|
|||||||
name="projectName"
|
name="projectName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel className="flex items-center gap-2">
|
||||||
To confirm, type "{data?.name}/{data?.appName}" in the box
|
<span>
|
||||||
below
|
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>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,13 +6,144 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ShieldCheck } from "lucide-react";
|
import { AlertCircle, Link, ShieldCheck } from "lucide-react";
|
||||||
import { AddCertificate } from "./add-certificate";
|
import { AddCertificate } from "./add-certificate";
|
||||||
import { DeleteCertificate } from "./delete-certificate";
|
import { DeleteCertificate } from "./delete-certificate";
|
||||||
|
|
||||||
export const ShowCertificates = () => {
|
export const ShowCertificates = () => {
|
||||||
const { data } = api.certificates.all.useQuery();
|
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 (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<Card className="bg-transparent h-full">
|
<Card className="bg-transparent h-full">
|
||||||
@@ -23,7 +154,7 @@ export const ShowCertificates = () => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 pt-4 h-full">
|
<CardContent className="space-y-2 pt-4 h-full">
|
||||||
{data?.length === 0 ? (
|
{!data?.length ? (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<ShieldCheck className="size-8 self-center text-muted-foreground" />
|
<ShieldCheck className="size-8 self-center text-muted-foreground" />
|
||||||
<span className="text-base 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="flex flex-col gap-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
{data?.map((destination, index) => (
|
{data.map((certificate, index) => {
|
||||||
<div
|
const expiration = getExpirationStatus(
|
||||||
key={destination.certificateId}
|
certificate.certificateData,
|
||||||
className="flex items-center justify-between border p-4 rounded-lg"
|
);
|
||||||
>
|
const chainInfo = getCertificateChainInfo(
|
||||||
<span className="text-sm text-muted-foreground">
|
certificate.certificateData,
|
||||||
{index + 1}. {destination.name}
|
);
|
||||||
</span>
|
return (
|
||||||
<div className="flex flex-row gap-3">
|
<div
|
||||||
<DeleteCertificate
|
key={certificate.certificateId}
|
||||||
certificateId={destination.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>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<AddCertificate />
|
<AddCertificate />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
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">
|
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card">
|
||||||
<code>
|
<code>
|
||||||
<pre className="whitespace-pre-wrap break-words">
|
<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>
|
</pre>
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,7 +107,24 @@ export const AddGithubProvider = () => {
|
|||||||
/>
|
/>
|
||||||
<br />
|
<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
|
<Button
|
||||||
disabled={isOrganization && organizationName.length < 1}
|
disabled={isOrganization && organizationName.length < 1}
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const Schema = z.object({
|
|||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
message: "Name is required",
|
message: "Name is required",
|
||||||
}),
|
}),
|
||||||
|
gitlabUrl: z.string().min(1, {
|
||||||
|
message: "GitLab URL is required",
|
||||||
|
}),
|
||||||
applicationId: z.string().min(1, {
|
applicationId: z.string().min(1, {
|
||||||
message: "Application ID is required",
|
message: "Application ID is required",
|
||||||
}),
|
}),
|
||||||
@@ -62,16 +65,22 @@ export const AddGitlabProvider = () => {
|
|||||||
applicationSecret: "",
|
applicationSecret: "",
|
||||||
groupName: "",
|
groupName: "",
|
||||||
redirectUri: webhookUrl,
|
redirectUri: webhookUrl,
|
||||||
|
name: "",
|
||||||
|
gitlabUrl: "https://gitlab.com",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gitlabUrl = form.watch("gitlabUrl");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
form.reset({
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
applicationSecret: "",
|
applicationSecret: "",
|
||||||
groupName: "",
|
groupName: "",
|
||||||
redirectUri: webhookUrl,
|
redirectUri: webhookUrl,
|
||||||
|
name: "",
|
||||||
|
gitlabUrl: "https://gitlab.com",
|
||||||
});
|
});
|
||||||
}, [form, isOpen]);
|
}, [form, isOpen]);
|
||||||
|
|
||||||
@@ -83,6 +92,7 @@ export const AddGitlabProvider = () => {
|
|||||||
authId: auth?.id || "",
|
authId: auth?.id || "",
|
||||||
name: data.name || "",
|
name: data.name || "",
|
||||||
redirectUri: data.redirectUri || "",
|
redirectUri: data.redirectUri || "",
|
||||||
|
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await utils.gitProvider.getAll.invalidate();
|
await utils.gitProvider.getAll.invalidate();
|
||||||
@@ -129,7 +139,7 @@ export const AddGitlabProvider = () => {
|
|||||||
<li className="flex flex-row gap-2 items-center">
|
<li className="flex flex-row gap-2 items-center">
|
||||||
Go to your GitLab profile settings{" "}
|
Go to your GitLab profile settings{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://gitlab.com/-/profile/applications"
|
href={`${gitlabUrl}/-/profile/applications`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-fit text-primary size-4" />
|
<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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="redirectUri"
|
name="redirectUri"
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const Schema = z.object({
|
|||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
message: "Name is required",
|
message: "Name is required",
|
||||||
}),
|
}),
|
||||||
|
gitlabUrl: z.string().url({
|
||||||
|
message: "Invalid Gitlab URL",
|
||||||
|
}),
|
||||||
groupName: z.string().optional(),
|
groupName: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,7 +43,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||||
const { data: gitlab } = api.gitlab.one.useQuery(
|
const { data: gitlab, refetch } = api.gitlab.one.useQuery(
|
||||||
{
|
{
|
||||||
gitlabId,
|
gitlabId,
|
||||||
},
|
},
|
||||||
@@ -57,6 +60,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
groupName: "",
|
groupName: "",
|
||||||
name: "",
|
name: "",
|
||||||
|
gitlabUrl: "https://gitlab.com",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
});
|
});
|
||||||
@@ -67,6 +71,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
|||||||
form.reset({
|
form.reset({
|
||||||
groupName: gitlab?.groupName || "",
|
groupName: gitlab?.groupName || "",
|
||||||
name: gitlab?.gitProvider.name || "",
|
name: gitlab?.gitProvider.name || "",
|
||||||
|
gitlabUrl: gitlab?.gitlabUrl || "",
|
||||||
});
|
});
|
||||||
}, [form, isOpen]);
|
}, [form, isOpen]);
|
||||||
|
|
||||||
@@ -76,11 +81,13 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
|||||||
gitProviderId: gitlab?.gitProviderId || "",
|
gitProviderId: gitlab?.gitProviderId || "",
|
||||||
groupName: data.groupName || "",
|
groupName: data.groupName || "",
|
||||||
name: data.name || "",
|
name: data.name || "",
|
||||||
|
gitlabUrl: data.gitlabUrl || "",
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await utils.gitProvider.getAll.invalidate();
|
await utils.gitProvider.getAll.invalidate();
|
||||||
toast.success("Gitlab updated successfully");
|
toast.success("Gitlab updated successfully");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to update Gitlab");
|
toast.error("Error to update Gitlab");
|
||||||
@@ -126,6 +133,19 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
|||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -23,12 +23,16 @@ export const ShowGitProviders = () => {
|
|||||||
|
|
||||||
const url = useUrl();
|
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 redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
|
||||||
|
|
||||||
const scope = "api read_user read_repository";
|
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;
|
return authUrl;
|
||||||
};
|
};
|
||||||
@@ -142,6 +146,7 @@ export const ShowGitProviders = () => {
|
|||||||
href={getGitlabUrl(
|
href={getGitlabUrl(
|
||||||
gitProvider.gitlab?.applicationId || "",
|
gitProvider.gitlab?.applicationId || "",
|
||||||
gitProvider.gitlab?.gitlabId || "",
|
gitProvider.gitlab?.gitlabId || "",
|
||||||
|
gitProvider.gitlab?.gitlabUrl,
|
||||||
)}
|
)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -24,8 +24,13 @@ export const DeleteNotification = ({ notificationId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button
|
||||||
<TrashIcon className="size-4 text-muted-foreground" />
|
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>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -40,48 +40,58 @@ export const ShowNotifications = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid lg:grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
{data?.map((notification, index) => (
|
{data?.map((notification, index) => (
|
||||||
<div
|
<div
|
||||||
key={notification.notificationId}
|
key={notification.notificationId}
|
||||||
className="flex items-center justify-between border gap-2 p-3.5 rounded-lg"
|
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 flex-row gap-2 items-center w-full ">
|
<div className="flex items-center gap-4">
|
||||||
{notification.notificationType === "slack" && (
|
{notification.notificationType === "slack" && (
|
||||||
<SlackIcon className="text-muted-foreground size-6 flex-shrink-0" />
|
<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" />
|
||||||
{notification.notificationType === "telegram" && (
|
</div>
|
||||||
<TelegramIcon className="text-muted-foreground size-8 flex-shrink-0" />
|
)}
|
||||||
)}
|
{notification.notificationType === "telegram" && (
|
||||||
{notification.notificationType === "discord" && (
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-500/10">
|
||||||
<DiscordIcon className="text-muted-foreground size-7 flex-shrink-0" />
|
<TelegramIcon className="h-6 w-6 text-indigo-400" />
|
||||||
)}
|
</div>
|
||||||
{notification.notificationType === "email" && (
|
)}
|
||||||
<Mail
|
{notification.notificationType === "discord" && (
|
||||||
size={29}
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
|
||||||
className="text-muted-foreground size-6 flex-shrink-0"
|
<DiscordIcon className="h-6 w-6 text-indigo-400" />
|
||||||
/>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm text-muted-foreground">
|
{notification.notificationType === "email" && (
|
||||||
{notification.name}
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-500/10">
|
||||||
</span>
|
<Mail className="h-6 w-6 text-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-row gap-1 w-fit">
|
<div className="flex flex-col">
|
||||||
<UpdateNotification
|
<span className="text-sm font-medium text-zinc-300">
|
||||||
notificationId={notification.notificationId}
|
{notification.name}
|
||||||
/>
|
</span>
|
||||||
<DeleteNotification
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
notificationId={notification.notificationId}
|
{notification.notificationType?.[0]?.toUpperCase() + notification.notificationType?.slice(1)} notification
|
||||||
/>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<UpdateNotification
|
||||||
<div className="flex flex-col gap-4 justify-end w-full items-end">
|
notificationId={notification.notificationId}
|
||||||
<AddNotification />
|
/>
|
||||||
|
<DeleteNotification
|
||||||
|
notificationId={notification.notificationId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 justify-end w-full items-end">
|
||||||
|
<AddNotification />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Mail, PenBoxIcon } from "lucide-react";
|
import { Mail, Pen } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { FieldErrors, useFieldArray, useForm } from "react-hook-form";
|
import { FieldErrors, useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -218,8 +218,10 @@ export const UpdateNotification = ({ notificationId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger className="" asChild>
|
<DialogTrigger className="" asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost"
|
||||||
<PenBoxIcon className="size-4 text-muted-foreground" />
|
size="icon"
|
||||||
|
className="h-9 w-9">
|
||||||
|
<Pen className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
<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 { z } from "zod";
|
||||||
import { Disable2FA } from "./disable-2fa";
|
import { Disable2FA } from "./disable-2fa";
|
||||||
import { Enable2FA } from "./enable-2fa";
|
import { Enable2FA } from "./enable-2fa";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
|
||||||
const profileSchema = z.object({
|
const profileSchema = z.object({
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
password: z.string().nullable(),
|
password: z.string().nullable(),
|
||||||
|
currentPassword: z.string().nullable(),
|
||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +54,8 @@ const randomImages = [
|
|||||||
|
|
||||||
export const ProfileForm = () => {
|
export const ProfileForm = () => {
|
||||||
const { data, refetch } = api.auth.get.useQuery();
|
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 { t } = useTranslation("settings");
|
||||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ export const ProfileForm = () => {
|
|||||||
email: data?.email || "",
|
email: data?.email || "",
|
||||||
password: "",
|
password: "",
|
||||||
image: data?.image || "",
|
image: data?.image || "",
|
||||||
|
currentPassword: "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
});
|
});
|
||||||
@@ -78,6 +82,7 @@ export const ProfileForm = () => {
|
|||||||
email: data?.email || "",
|
email: data?.email || "",
|
||||||
password: "",
|
password: "",
|
||||||
image: data?.image || "",
|
image: data?.image || "",
|
||||||
|
currentPassword: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data.email) {
|
if (data.email) {
|
||||||
@@ -94,10 +99,12 @@ export const ProfileForm = () => {
|
|||||||
email: values.email.toLowerCase(),
|
email: values.email.toLowerCase(),
|
||||||
password: values.password,
|
password: values.password,
|
||||||
image: values.image,
|
image: values.image,
|
||||||
|
currentPassword: values.currentPassword,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await refetch();
|
await refetch();
|
||||||
toast.success("Profile Updated");
|
toast.success("Profile Updated");
|
||||||
|
form.reset();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to Update the profile");
|
toast.error("Error to Update the profile");
|
||||||
@@ -116,6 +123,8 @@ export const ProfileForm = () => {
|
|||||||
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
|
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -135,6 +144,24 @@ export const ProfileForm = () => {
|
|||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
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 { useTranslation } from "next-i18next";
|
||||||
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
|
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
|
||||||
import { ShowModalLogs } from "../../web-server/show-modal-logs";
|
import { ShowModalLogs } from "../../web-server/show-modal-logs";
|
||||||
|
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
@@ -128,6 +129,14 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
<span>Enter the terminal</span>
|
<span>Enter the terminal</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DockerTerminalModal> */}
|
</DockerTerminalModal> */}
|
||||||
|
<ManageTraefikPorts serverId={serverId}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</ManageTraefikPorts>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
{showIcon ? (
|
{showIcon ? (
|
||||||
<>
|
<>
|
||||||
{isEnabled ? (
|
|
||||||
<CheckCircle2 className="size-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="size-4 text-red-500" />
|
|
||||||
)}
|
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${isEnabled ? "text-green-500" : "text-red-500"}`}
|
className={`text-sm ${isEnabled ? "text-green-500" : "text-red-500"}`}
|
||||||
>
|
>
|
||||||
{description || (isEnabled ? "Installed" : "Not Installed")}
|
{description || (isEnabled ? "Installed" : "Not Installed")}
|
||||||
</span>
|
</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>
|
<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,8 +32,10 @@ import Link from "next/link";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ShowDeployment } from "../../application/deployments/show-deployment";
|
import { ShowDeployment } from "../../application/deployments/show-deployment";
|
||||||
|
import { EditScript } from "./edit-script";
|
||||||
import { GPUSupport } from "./gpu-support";
|
import { GPUSupport } from "./gpu-support";
|
||||||
import { ValidateServer } from "./validate-server";
|
import { ValidateServer } from "./validate-server";
|
||||||
|
import { SecurityAudit } from "./security-audit";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -89,12 +91,18 @@ export const SetupServer = ({ serverId }: Props) => {
|
|||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
</div>
|
</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">
|
<Tabs defaultValue="ssh-keys">
|
||||||
<TabsList className="grid grid-cols-4 w-[600px]">
|
<TabsList className="grid grid-cols-5 w-[700px]">
|
||||||
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
<TabsTrigger value="validate">Validate</TabsTrigger>
|
<TabsTrigger value="validate">Validate</TabsTrigger>
|
||||||
|
<TabsTrigger value="audit">Security</TabsTrigger>
|
||||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent
|
<TabsContent
|
||||||
@@ -139,7 +147,7 @@ export const SetupServer = ({ serverId }: Props) => {
|
|||||||
Automatic process
|
Automatic process
|
||||||
</span>
|
</span>
|
||||||
<Link
|
<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"
|
target="_blank"
|
||||||
className="text-primary flex flex-row gap-2"
|
className="text-primary flex flex-row gap-2"
|
||||||
>
|
>
|
||||||
@@ -198,6 +206,28 @@ export const SetupServer = ({ serverId }: Props) => {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="deployments">
|
<TabsContent value="deployments">
|
||||||
@@ -214,24 +244,29 @@ export const SetupServer = ({ serverId }: Props) => {
|
|||||||
See all the 5 Server Setup
|
See all the 5 Server Setup
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<DialogAction
|
<div className="flex flex-row gap-2">
|
||||||
title={"Setup Server?"}
|
<EditScript serverId={server?.serverId || ""} />
|
||||||
description="This will setup the server and all associated data"
|
<DialogAction
|
||||||
onClick={async () => {
|
title={"Setup Server?"}
|
||||||
await mutateAsync({
|
description="This will setup the server and all associated data"
|
||||||
serverId: server?.serverId || "",
|
onClick={async () => {
|
||||||
})
|
await mutateAsync({
|
||||||
.then(async () => {
|
serverId: server?.serverId || "",
|
||||||
refetch();
|
|
||||||
toast.success("Server setup successfully");
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.then(async () => {
|
||||||
toast.error("Error configuring server");
|
refetch();
|
||||||
});
|
toast.success("Server setup successfully");
|
||||||
}}
|
})
|
||||||
>
|
.catch(() => {
|
||||||
<Button isLoading={isLoading}>Setup Server</Button>
|
toast.error("Error configuring server");
|
||||||
</DialogAction>
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button isLoading={isLoading}>
|
||||||
|
Setup Server
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
@@ -303,6 +338,14 @@ export const SetupServer = ({ serverId }: Props) => {
|
|||||||
<ValidateServer serverId={serverId} />
|
<ValidateServer serverId={serverId} />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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
|
<TabsContent
|
||||||
value="gpu-setup"
|
value="gpu-setup"
|
||||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
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 { ShowDockerContainersModal } from "./show-docker-containers-modal";
|
||||||
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
|
||||||
import { UpdateServer } from "./update-server";
|
import { UpdateServer } from "./update-server";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
|
||||||
|
|
||||||
export const ShowServers = () => {
|
export const ShowServers = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const query = router.query;
|
||||||
const { data, refetch } = api.server.all.useQuery();
|
const { data, refetch } = api.server.all.useQuery();
|
||||||
const { mutateAsync } = api.server.remove.useMutation();
|
const { mutateAsync } = api.server.remove.useMutation();
|
||||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||||
@@ -42,12 +46,26 @@ export const ShowServers = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
|
{query?.success && isCloud && <WelcomeSuscription />}
|
||||||
<div className="space-y-2 flex flex-row justify-between items-end">
|
<div className="space-y-2 flex flex-row justify-between items-end">
|
||||||
<div>
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-2xl font-bold">Servers</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">
|
<h1 className="text-2xl font-bold">Servers</h1>
|
||||||
Add servers to deploy your applications remotely.
|
<p className="text-muted-foreground">
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
|
||||||
{sshKeys && sshKeys?.length > 0 && (
|
{sshKeys && sshKeys?.length > 0 && (
|
||||||
@@ -100,7 +118,9 @@ export const ShowServers = () => {
|
|||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<div className="flex flex-col gap-6 overflow-auto">
|
<div className="flex flex-col gap-6 overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableCaption>See all servers</TableCaption>
|
<TableCaption>
|
||||||
|
<div className="flex flex-col gap-4">See all servers</div>
|
||||||
|
</TableCaption>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[100px]">Name</TableHead>
|
<TableHead className="w-[100px]">Name</TableHead>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -8,9 +9,8 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { Loader2, PcCase, RefreshCw } from "lucide-react";
|
import { Loader2, PcCase, RefreshCw } from "lucide-react";
|
||||||
import { StatusRow } from "./gpu-support";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { StatusRow } from "./gpu-support";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -66,7 +66,7 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center text-muted-foreground py-4">
|
<div className="flex items-center justify-center text-muted-foreground py-4">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<span>Checking Server Configuration</span>
|
<span>Checking Server configuration</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid w-full gap-4">
|
<div className="grid w-full gap-4">
|
||||||
@@ -113,16 +113,31 @@ export const ValidateServer = ({ serverId }: Props) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StatusRow
|
<StatusRow
|
||||||
label="Dokploy Network Installed"
|
label="Docker Swarm Initialized"
|
||||||
isEnabled={data?.isDokployNetworkInstalled}
|
isEnabled={data?.isSwarmInstalled}
|
||||||
|
description={
|
||||||
|
data?.isSwarmInstalled
|
||||||
|
? "Initialized"
|
||||||
|
: "Not Initialized"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<StatusRow
|
<StatusRow
|
||||||
label="Swarm Installed"
|
label="Dokploy Network Created"
|
||||||
isEnabled={data?.isSwarmInstalled}
|
isEnabled={data?.isDokployNetworkInstalled}
|
||||||
|
description={
|
||||||
|
data?.isDokployNetworkInstalled
|
||||||
|
? "Created"
|
||||||
|
: "Not Created"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<StatusRow
|
<StatusRow
|
||||||
label="Main Directory Created"
|
label="Main Directory Created"
|
||||||
isEnabled={data?.isMainDirectoryInstalled}
|
isEnabled={data?.isMainDirectoryInstalled}
|
||||||
|
description={
|
||||||
|
data?.isMainDirectoryInstalled
|
||||||
|
? "Created"
|
||||||
|
: "Not Created"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<DockerLogsId
|
<DockerLogsId containerId={containerId || ""} serverId={serverId} />
|
||||||
id="terminal"
|
|
||||||
containerId={containerId || ""}
|
|
||||||
serverId={serverId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
|
|||||||
import { FitAddon } from "xterm-addon-fit";
|
import { FitAddon } from "xterm-addon-fit";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { AttachAddon } from "@xterm/addon-attach";
|
import { AttachAddon } from "@xterm/addon-attach";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -12,7 +13,7 @@ interface Props {
|
|||||||
|
|
||||||
export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||||
const termRef = useRef(null);
|
const termRef = useRef(null);
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = document.getElementById(id);
|
const container = document.getElementById(id);
|
||||||
if (container) {
|
if (container) {
|
||||||
@@ -20,13 +21,12 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
|||||||
}
|
}
|
||||||
const term = new XTerm({
|
const term = new XTerm({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cols: 80,
|
|
||||||
rows: 30,
|
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
theme: {
|
theme: {
|
||||||
cursor: "transparent",
|
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
|
||||||
background: "#19191A",
|
background: "rgba(0, 0, 0, 0)",
|
||||||
|
foreground: "currentColor",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const addonFit = new FitAddon();
|
const addonFit = new FitAddon();
|
||||||
@@ -40,6 +40,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
|||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
term.open(termRef.current);
|
term.open(termRef.current);
|
||||||
|
// @ts-ignore
|
||||||
term.loadAddon(addonFit);
|
term.loadAddon(addonFit);
|
||||||
term.loadAddon(addonAttach);
|
term.loadAddon(addonAttach);
|
||||||
addonFit.fit();
|
addonFit.fit();
|
||||||
@@ -50,7 +51,7 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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 id={id} ref={termRef} className="rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { api } from "@/utils/api";
|
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 Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates";
|
||||||
import { UpdateWebServer } from "./update-webserver";
|
import { UpdateWebServer } from "./update-webserver";
|
||||||
|
|
||||||
export const UpdateServer = () => {
|
export const UpdateServer = () => {
|
||||||
const [isUpdateAvailable, setIsUpdateAvailable] = useState<null | boolean>(
|
const [hasCheckedUpdate, setHasCheckedUpdate] = useState(false);
|
||||||
null,
|
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
|
||||||
);
|
const { mutateAsync: getUpdateData, isLoading } =
|
||||||
const { mutateAsync: checkAndUpdateImage, isLoading } =
|
api.settings.getUpdateData.useMutation();
|
||||||
api.settings.checkAndUpdateImage.useMutation();
|
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||||
|
const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
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 (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="secondary">
|
<Button variant="secondary" className="gap-2">
|
||||||
<RefreshCcw className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
Updates
|
Updates
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:m:max-w-lg ">
|
<DialogContent className="max-w-lg p-6">
|
||||||
<DialogHeader>
|
<div className="flex items-center justify-between mb-8">
|
||||||
<DialogTitle>Web Server Update</DialogTitle>
|
<DialogTitle className="text-2xl font-semibold">
|
||||||
<DialogDescription>
|
Web Server Update
|
||||||
Check new releases and update your dokploy
|
</DialogTitle>
|
||||||
</DialogDescription>
|
{dokployVersion && (
|
||||||
</DialogHeader>
|
<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">
|
{/* Initial state */}
|
||||||
<span className="text-sm text-muted-foreground">
|
{!hasCheckedUpdate && (
|
||||||
We suggest to update your dokploy to the latest version only if you:
|
<div className="mb-8">
|
||||||
</span>
|
<p className="text text-muted-foreground">
|
||||||
<ul className="list-disc list-inside text-sm text-muted-foreground">
|
Check for new releases and update Dokploy.
|
||||||
<li>Want to try the latest features</li>
|
<br />
|
||||||
<li>Some bug that is blocking to use some features</li>
|
<br />
|
||||||
</ul>
|
We recommend checking for updates regularly to ensure you have the
|
||||||
<AlertBlock type="info">
|
latest features and security improvements.
|
||||||
We recommend checking the latest version for any breaking changes
|
</p>
|
||||||
before updating. Go to{" "}
|
</div>
|
||||||
<Link
|
)}
|
||||||
href="https://github.com/Dokploy/dokploy/releases"
|
|
||||||
target="_blank"
|
|
||||||
className="text-foreground"
|
|
||||||
>
|
|
||||||
Dokploy Releases
|
|
||||||
</Link>{" "}
|
|
||||||
to check the latest version.
|
|
||||||
</AlertBlock>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4">
|
{/* Update available state */}
|
||||||
{isUpdateAvailable === false && (
|
{isUpdateAvailable && latestVersion && (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="mb-8">
|
||||||
<RefreshCcw className="size-6 self-center text-muted-foreground" />
|
<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">
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5">
|
||||||
You are using the latest version
|
<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>
|
</span>
|
||||||
</div>
|
</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 ? (
|
{isUpdateAvailable ? (
|
||||||
<UpdateWebServer />
|
<UpdateWebServer />
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
variant="secondary"
|
||||||
onClick={async () => {
|
onClick={handleCheckUpdates}
|
||||||
await checkAndUpdateImage()
|
disabled={isLoading}
|
||||||
.then(async (e) => {
|
|
||||||
setIsUpdateAvailable(e);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setIsUpdateAvailable(false);
|
|
||||||
toast.error("Error to check updates");
|
|
||||||
});
|
|
||||||
toast.success("Check updates");
|
|
||||||
}}
|
|
||||||
isLoading={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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -96,3 +229,5 @@ export const UpdateServer = () => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default UpdateServer;
|
||||||
|
|||||||
@@ -11,24 +11,53 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { HardDriveDownload } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const UpdateWebServer = () => {
|
interface Props {
|
||||||
|
isNavbar?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateWebServer = ({ isNavbar }: Props) => {
|
||||||
const { mutateAsync: updateServer, isLoading } =
|
const { mutateAsync: updateServer, isLoading } =
|
||||||
api.settings.updateServer.useMutation();
|
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 (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
variant="secondary"
|
variant={isNavbar ? "outline" : "secondary"}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
>
|
>
|
||||||
<span className="absolute -right-1 -top-2 flex h-3 w-3">
|
{!isLoading && <HardDriveDownload className="h-4 w-4" />}
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
{!isLoading && (
|
||||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
|
<span className="absolute -right-1 -top-2 flex h-3 w-3">
|
||||||
</span>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||||
Update Server
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLoading ? "Updating..." : buttonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -36,19 +65,12 @@ export const UpdateWebServer = () => {
|
|||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will update the web server to the
|
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>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction onClick={handleConfirm}>Confirm</AlertDialogAction>
|
||||||
onClick={async () => {
|
|
||||||
await updateServer();
|
|
||||||
toast.success("Please reload the browser to see the changes");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
|
import Head from "next/head";
|
||||||
import { Navbar } from "./navbar";
|
import { Navbar } from "./navbar";
|
||||||
import { NavigationTabs, type TabState } from "./navigation-tabs";
|
import { NavigationTabs, type TabState } from "./navigation-tabs";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
tab: TabState;
|
tab: TabState;
|
||||||
|
metaName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardLayout = ({ children, tab }: Props) => {
|
export const DashboardLayout = ({ children, tab, metaName }: Props) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div
|
<Head>
|
||||||
className="bg-radial relative flex flex-col bg-background min-h-screen w-full"
|
<title>
|
||||||
id="app-container"
|
{metaName ?? tab.charAt(0).toUpperCase() + tab.slice(1)} | Dokploy
|
||||||
>
|
</title>
|
||||||
<Navbar />
|
</Head>
|
||||||
<main className="pt-6 flex w-full flex-col items-center">
|
<div>
|
||||||
<div className="w-full max-w-8xl px-4 lg:px-8">
|
<div
|
||||||
<NavigationTabs tab={tab}>{children}</NavigationTabs>
|
className="bg-radial relative flex flex-col bg-background min-h-screen w-full"
|
||||||
</div>
|
id="app-container"
|
||||||
</main>
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ import { api } from "@/utils/api";
|
|||||||
import { HeartIcon } from "lucide-react";
|
import { HeartIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
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 { Logo } from "../shared/logo";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||||
import { buttonVariants } from "../ui/button";
|
import { buttonVariants } from "../ui/button";
|
||||||
|
|
||||||
|
const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
|
||||||
|
|
||||||
export const Navbar = () => {
|
export const Navbar = () => {
|
||||||
|
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data } = api.auth.get.useQuery();
|
const { data } = api.auth.get.useQuery();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
@@ -29,6 +34,59 @@ export const Navbar = () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const { mutateAsync } = api.auth.logout.useMutation();
|
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 (
|
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">
|
<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">
|
<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>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
{isUpdateAvailable && (
|
||||||
|
<div>
|
||||||
|
<UpdateWebServer isNavbar />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
variant: "outline",
|
variant: "outline",
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const SettingsLayout = ({ children }: Props) => {
|
|||||||
{
|
{
|
||||||
title: "Registry",
|
title: "Registry",
|
||||||
label: "",
|
label: "",
|
||||||
icon: ListMusic,
|
icon: GalleryVerticalEnd,
|
||||||
href: "/dashboard/settings/registry",
|
href: "/dashboard/settings/registry",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -150,6 +150,7 @@ import {
|
|||||||
BoxesIcon,
|
BoxesIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
Database,
|
Database,
|
||||||
|
GalleryVerticalEnd,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { cn } from "@/lib/utils";
|
|||||||
import { json } from "@codemirror/lang-json";
|
import { json } from "@codemirror/lang-json";
|
||||||
import { yaml } from "@codemirror/lang-yaml";
|
import { yaml } from "@codemirror/lang-yaml";
|
||||||
import { StreamLanguage } from "@codemirror/language";
|
import { StreamLanguage } from "@codemirror/language";
|
||||||
|
|
||||||
import { properties } from "@codemirror/legacy-modes/mode/properties";
|
import { properties } from "@codemirror/legacy-modes/mode/properties";
|
||||||
|
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||||
import { EditorView } from "@codemirror/view";
|
import { EditorView } from "@codemirror/view";
|
||||||
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
|
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
|
||||||
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||||
@@ -10,14 +12,16 @@ import { useTheme } from "next-themes";
|
|||||||
interface Props extends ReactCodeMirrorProps {
|
interface Props extends ReactCodeMirrorProps {
|
||||||
wrapperClassName?: string;
|
wrapperClassName?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
language?: "yaml" | "json" | "properties";
|
language?: "yaml" | "json" | "properties" | "shell";
|
||||||
lineWrapping?: boolean;
|
lineWrapping?: boolean;
|
||||||
|
lineNumbers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CodeEditor = ({
|
export const CodeEditor = ({
|
||||||
className,
|
className,
|
||||||
wrapperClassName,
|
wrapperClassName,
|
||||||
language = "yaml",
|
language = "yaml",
|
||||||
|
lineNumbers = true,
|
||||||
...props
|
...props
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
@@ -25,7 +29,7 @@ export const CodeEditor = ({
|
|||||||
<div className={cn("relative overflow-auto", wrapperClassName)}>
|
<div className={cn("relative overflow-auto", wrapperClassName)}>
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
basicSetup={{
|
basicSetup={{
|
||||||
lineNumbers: true,
|
lineNumbers,
|
||||||
foldGutter: true,
|
foldGutter: true,
|
||||||
highlightSelectionMatches: true,
|
highlightSelectionMatches: true,
|
||||||
highlightActiveLine: !props.disabled,
|
highlightActiveLine: !props.disabled,
|
||||||
@@ -37,7 +41,9 @@ export const CodeEditor = ({
|
|||||||
? yaml()
|
? yaml()
|
||||||
: language === "json"
|
: language === "json"
|
||||||
? json()
|
? json()
|
||||||
: StreamLanguage.define(properties),
|
: language === "shell"
|
||||||
|
? StreamLanguage.define(shell)
|
||||||
|
: StreamLanguage.define(properties),
|
||||||
props.lineWrapping ? EditorView.lineWrapping : [],
|
props.lineWrapping ? EditorView.lineWrapping : [],
|
||||||
]}
|
]}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ const badgeVariants = cva(
|
|||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
"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",
|
outline: "text-foreground",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const Secrets = (props: Props) => {
|
|||||||
}
|
}
|
||||||
language="properties"
|
language="properties"
|
||||||
disabled={isVisible}
|
disabled={isVisible}
|
||||||
|
lineWrapping
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
className="h-96 font-mono"
|
className="h-96 font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const Tooltip = TooltipPrimitive.Root;
|
|||||||
|
|
||||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
const TooltipPortal = TooltipPrimitive.Portal;
|
||||||
|
|
||||||
const TooltipContent = React.forwardRef<
|
const TooltipContent = React.forwardRef<
|
||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
@@ -25,4 +27,10 @@ const TooltipContent = React.forwardRef<
|
|||||||
));
|
));
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
export {
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipPortal,
|
||||||
|
};
|
||||||
|
|||||||
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;
|
||||||
4233
apps/dokploy/drizzle/meta/0050_snapshot.json
Normal file
4233
apps/dokploy/drizzle/meta/0050_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4240
apps/dokploy/drizzle/meta/0051_snapshot.json
Normal file
4240
apps/dokploy/drizzle/meta/0051_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -351,6 +351,20 @@
|
|||||||
"when": 1733628762978,
|
"when": 1733628762978,
|
||||||
"tag": "0049_dark_leopardon",
|
"tag": "0049_dark_leopardon",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 50,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1733889104203,
|
||||||
|
"tag": "0050_nappy_wrecker",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 51,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1734241482851,
|
||||||
|
"tag": "0051_hard_gorgon",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,8 @@ export enum Languages {
|
|||||||
Persian = "fa",
|
Persian = "fa",
|
||||||
Korean = "ko",
|
Korean = "ko",
|
||||||
Portuguese = "pt-br",
|
Portuguese = "pt-br",
|
||||||
|
Italian = "it",
|
||||||
|
Japanese = "ja",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Language = keyof typeof Languages;
|
export type Language = keyof typeof Languages;
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
/** @type {import('next-i18next').UserConfig} */
|
|
||||||
module.exports = {
|
|
||||||
fallbackLng: "en",
|
|
||||||
keySeparator: false,
|
|
||||||
i18n: {
|
|
||||||
defaultLocale: "en",
|
|
||||||
locales: [
|
|
||||||
"en",
|
|
||||||
"pl",
|
|
||||||
"ru",
|
|
||||||
"fr",
|
|
||||||
"de",
|
|
||||||
"tr",
|
|
||||||
"kz",
|
|
||||||
"zh-Hant",
|
|
||||||
"zh-Hans",
|
|
||||||
"fa",
|
|
||||||
"ko",
|
|
||||||
"pt-br",
|
|
||||||
],
|
|
||||||
localeDetection: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.14.0",
|
"version": "v0.15.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -35,6 +35,8 @@
|
|||||||
"test": "vitest --config __test__/vitest.config.ts"
|
"test": "vitest --config __test__/vitest.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"react-confetti-explosion":"2.1.2",
|
||||||
|
"@stepperize/react": "4.0.1",
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
"@codemirror/lang-yaml": "^6.1.1",
|
"@codemirror/lang-yaml": "^6.1.1",
|
||||||
"@codemirror/language": "^6.10.1",
|
"@codemirror/language": "^6.10.1",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
|
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Languages } from "@/lib/languages";
|
import { Languages } from "@/lib/languages";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -33,10 +34,10 @@ const MyApp = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style jsx global>{`
|
<style jsx global>{`
|
||||||
:root {
|
:root {
|
||||||
--font-inter: ${inter.style.fontFamily};
|
--font-inter: ${inter.style.fontFamily};
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Dokploy</title>
|
<title>Dokploy</title>
|
||||||
</Head>
|
</Head>
|
||||||
@@ -56,6 +57,7 @@ const MyApp = ({
|
|||||||
forcedTheme={Component.theme}
|
forcedTheme={Component.theme}
|
||||||
>
|
>
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
|
<SearchCommand />
|
||||||
{getLayout(<Component {...pageProps} />)}
|
{getLayout(<Component {...pageProps} />)}
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</>
|
</>
|
||||||
@@ -63,20 +65,13 @@ const MyApp = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default api.withTRPC(
|
export default api.withTRPC(
|
||||||
appWithTranslation(
|
appWithTranslation(MyApp, {
|
||||||
MyApp,
|
i18n: {
|
||||||
// keep this in sync with next-i18next.config.js
|
defaultLocale: "en",
|
||||||
// if you want to know why don't just import the config file, this because next-i18next.config.js must be a CJS, but the rest of the code is ESM.
|
locales: Object.values(Languages),
|
||||||
// Add the config here is due to the issue: https://github.com/i18next/next-i18next/issues/2259
|
localeDetection: false,
|
||||||
// if one day every page is translated, we can safely remove this config.
|
|
||||||
{
|
|
||||||
i18n: {
|
|
||||||
defaultLocale: "en",
|
|
||||||
locales: Object.values(Languages),
|
|
||||||
localeDetection: false,
|
|
||||||
},
|
|
||||||
fallbackLng: "en",
|
|
||||||
keySeparator: false,
|
|
||||||
},
|
},
|
||||||
),
|
fallbackLng: "en",
|
||||||
|
keySeparator: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default async function handler(
|
|||||||
|
|
||||||
const gitlab = await findGitlabById(gitlabId as string);
|
const gitlab = await findGitlabById(gitlabId as string);
|
||||||
|
|
||||||
const response = await fetch("https://gitlab.com/oauth/token", {
|
const response = await fetch(`${gitlab.gitlabUrl}/oauth/token`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type {
|
|||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
InferGetServerSidePropsType,
|
InferGetServerSidePropsType,
|
||||||
} from "next";
|
} from "next";
|
||||||
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { type ReactElement } from "react";
|
import React, { type ReactElement } from "react";
|
||||||
@@ -189,6 +190,9 @@ const Project = (
|
|||||||
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
<Head>
|
||||||
|
<title>Project: {data?.name} | Dokploy</title>
|
||||||
|
</Head>
|
||||||
<header className="mb-6 flex w-full items-center justify-between flex-wrap gap-2">
|
<header className="mb-6 flex w-full items-center justify-between flex-wrap gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-xl font-bold lg:text-3xl">{data?.name}</h1>
|
<h1 className="text-xl font-bold lg:text-3xl">{data?.name}</h1>
|
||||||
|
|||||||
@@ -41,9 +41,10 @@ import type {
|
|||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
InferGetServerSidePropsType,
|
InferGetServerSidePropsType,
|
||||||
} from "next";
|
} from "next";
|
||||||
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useState, type ReactElement } from "react";
|
import React, { useState, useEffect, type ReactElement } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
type TabState =
|
type TabState =
|
||||||
@@ -61,7 +62,14 @@ const Service = (
|
|||||||
const { applicationId, activeTab } = props;
|
const { applicationId, activeTab } = props;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { projectId } = router.query;
|
const { projectId } = router.query;
|
||||||
const [tab, setSab] = useState<TabState>(activeTab);
|
const [tab, setTab] = useState<TabState>(activeTab);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.query.tab) {
|
||||||
|
setTab(router.query.tab as TabState);
|
||||||
|
}
|
||||||
|
}, [router.query.tab]);
|
||||||
|
|
||||||
const { data } = api.application.one.useQuery(
|
const { data } = api.application.one.useQuery(
|
||||||
{ applicationId },
|
{ applicationId },
|
||||||
{
|
{
|
||||||
@@ -101,6 +109,11 @@ const Service = (
|
|||||||
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
Application: {data?.name} - {data?.project.name} | Dokploy
|
||||||
|
</title>
|
||||||
|
</Head>
|
||||||
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
||||||
<div className="flex flex-col justify-between w-fit gap-2">
|
<div className="flex flex-col justify-between w-fit gap-2">
|
||||||
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
||||||
@@ -185,9 +198,9 @@ const Service = (
|
|||||||
defaultValue="general"
|
defaultValue="general"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onValueChange={(e) => {
|
onValueChange={(e) => {
|
||||||
setSab(e as TabState);
|
setTab(e as TabState);
|
||||||
const newPath = `/dashboard/project/${projectId}/services/application/${applicationId}?tab=${e}`;
|
const newPath = `/dashboard/project/${projectId}/services/application/${applicationId}?tab=${e}`;
|
||||||
router.push(newPath, undefined, { shallow: true });
|
router.push(newPath);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deploymen
|
|||||||
import { ShowDomainsCompose } from "@/components/dashboard/compose/domains/show-domains";
|
import { ShowDomainsCompose } from "@/components/dashboard/compose/domains/show-domains";
|
||||||
import { ShowEnvironmentCompose } from "@/components/dashboard/compose/enviroment/show";
|
import { ShowEnvironmentCompose } from "@/components/dashboard/compose/enviroment/show";
|
||||||
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
|
||||||
import { ImportTemplate } from "@/components/dashboard/compose/import-template";
|
|
||||||
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
||||||
import { ShowMonitoringCompose } from "@/components/dashboard/compose/monitoring/show";
|
import { ShowMonitoringCompose } from "@/components/dashboard/compose/monitoring/show";
|
||||||
import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
|
import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
|
||||||
@@ -36,9 +35,10 @@ import type {
|
|||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
InferGetServerSidePropsType,
|
InferGetServerSidePropsType,
|
||||||
} from "next";
|
} from "next";
|
||||||
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useState, type ReactElement } from "react";
|
import React, { useState, useEffect, type ReactElement } from "react";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
|
|
||||||
type TabState =
|
type TabState =
|
||||||
@@ -55,7 +55,14 @@ const Service = (
|
|||||||
const { composeId, activeTab } = props;
|
const { composeId, activeTab } = props;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { projectId } = router.query;
|
const { projectId } = router.query;
|
||||||
const [tab, setSab] = useState<TabState>(activeTab);
|
const [tab, setTab] = useState<TabState>(activeTab);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.query.tab) {
|
||||||
|
setTab(router.query.tab as TabState);
|
||||||
|
}
|
||||||
|
}, [router.query.tab]);
|
||||||
|
|
||||||
const { data } = api.compose.one.useQuery(
|
const { data } = api.compose.one.useQuery(
|
||||||
{ composeId },
|
{ composeId },
|
||||||
{
|
{
|
||||||
@@ -95,6 +102,11 @@ const Service = (
|
|||||||
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
Compose: {data?.name} - {data?.project.name} | Dokploy
|
||||||
|
</title>
|
||||||
|
</Head>
|
||||||
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
||||||
<div className="flex flex-col justify-between w-fit gap-2">
|
<div className="flex flex-col justify-between w-fit gap-2">
|
||||||
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
||||||
@@ -178,9 +190,9 @@ const Service = (
|
|||||||
defaultValue="general"
|
defaultValue="general"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onValueChange={(e) => {
|
onValueChange={(e) => {
|
||||||
setSab(e as TabState);
|
setTab(e as TabState);
|
||||||
const newPath = `/dashboard/project/${projectId}/services/compose/${composeId}?tab=${e}`;
|
const newPath = `/dashboard/project/${projectId}/services/compose/${composeId}?tab=${e}`;
|
||||||
router.push(newPath, undefined, { shallow: true });
|
router.push(newPath);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||||
@@ -207,7 +219,6 @@ const Service = (
|
|||||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<ImportTemplate composeId={composeId} />
|
|
||||||
<UpdateCompose composeId={composeId} />
|
<UpdateCompose composeId={composeId} />
|
||||||
|
|
||||||
{(auth?.rol === "admin" || user?.canDeleteServices) && (
|
{(auth?.rol === "admin" || user?.canDeleteServices) && (
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type {
|
|||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
InferGetServerSidePropsType,
|
InferGetServerSidePropsType,
|
||||||
} from "next";
|
} from "next";
|
||||||
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useState, type ReactElement } from "react";
|
import React, { useState, type ReactElement } from "react";
|
||||||
@@ -82,6 +83,11 @@ const Mariadb = (
|
|||||||
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
Database: {data?.name} - {data?.project.name} | Dokploy
|
||||||
|
</title>
|
||||||
|
</Head>
|
||||||
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
||||||
<div className="flex flex-col justify-between w-fit gap-2">
|
<div className="flex flex-col justify-between w-fit gap-2">
|
||||||
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type {
|
|||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
InferGetServerSidePropsType,
|
InferGetServerSidePropsType,
|
||||||
} from "next";
|
} from "next";
|
||||||
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useState, type ReactElement } from "react";
|
import React, { useState, type ReactElement } from "react";
|
||||||
@@ -83,6 +84,11 @@ const Mongo = (
|
|||||||
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
Database: {data?.name} - {data?.project.name} | Dokploy
|
||||||
|
</title>
|
||||||
|
</Head>
|
||||||
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
||||||
<div className="flex flex-col justify-between w-fit gap-2">
|
<div className="flex flex-col justify-between w-fit gap-2">
|
||||||
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type {
|
|||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
InferGetServerSidePropsType,
|
InferGetServerSidePropsType,
|
||||||
} from "next";
|
} from "next";
|
||||||
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useState, type ReactElement } from "react";
|
import React, { useState, type ReactElement } from "react";
|
||||||
@@ -81,6 +82,11 @@ const MySql = (
|
|||||||
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
Database: {data?.name} - {data?.project.name} | Dokploy
|
||||||
|
</title>
|
||||||
|
</Head>
|
||||||
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
||||||
<div className="flex flex-col justify-between w-fit gap-2">
|
<div className="flex flex-col justify-between w-fit gap-2">
|
||||||
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import type {
|
|||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
InferGetServerSidePropsType,
|
InferGetServerSidePropsType,
|
||||||
} from "next";
|
} from "next";
|
||||||
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useState, type ReactElement } from "react";
|
import React, { useState, type ReactElement } from "react";
|
||||||
@@ -82,6 +83,11 @@ const Postgresql = (
|
|||||||
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
Database: {data?.name} - {data?.project.name} | Dokploy
|
||||||
|
</title>
|
||||||
|
</Head>
|
||||||
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
||||||
<div className="flex flex-col justify-between w-fit gap-2">
|
<div className="flex flex-col justify-between w-fit gap-2">
|
||||||
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import type {
|
|||||||
GetServerSidePropsContext,
|
GetServerSidePropsContext,
|
||||||
InferGetServerSidePropsType,
|
InferGetServerSidePropsType,
|
||||||
} from "next";
|
} from "next";
|
||||||
|
import Head from "next/head";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import React, { useState, type ReactElement } from "react";
|
import React, { useState, type ReactElement } from "react";
|
||||||
@@ -81,6 +82,11 @@ const Redis = (
|
|||||||
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
<BreadcrumbLink>{data?.name}</BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
|
<Head>
|
||||||
|
<title>
|
||||||
|
Database: {data?.name} - {data?.project.name} | Dokploy
|
||||||
|
</title>
|
||||||
|
</Head>
|
||||||
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
<header className="mb-6 flex w-full items-center justify-between max-sm:flex-wrap gap-4">
|
||||||
<div className="flex flex-col justify-between w-fit gap-2">
|
<div className="flex flex-col justify-between w-fit gap-2">
|
||||||
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
<div className="flex flex-row items-center gap-2 xl:gap-4 flex-wrap">
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="Appearance">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="Billing">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="Certificates">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="Nodes">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="S3 Destinations">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="Git Providers">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="Notifications">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { GenerateToken } from "@/components/dashboard/settings/profile/generate-token";
|
import { GenerateToken } from "@/components/dashboard/settings/profile/generate-token";
|
||||||
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
|
import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form";
|
||||||
|
import { RemoveSelfAccount } from "@/components/dashboard/settings/profile/remove-self-account";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { SettingsLayout } from "@/components/layouts/settings-layout";
|
import { SettingsLayout } from "@/components/layouts/settings-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
@@ -21,10 +22,14 @@ const Page = () => {
|
|||||||
enabled: !!data?.id && data?.rol === "user",
|
enabled: !!data?.id && data?.rol === "user",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<div className="flex flex-col gap-4 w-full">
|
||||||
<ProfileForm />
|
<ProfileForm />
|
||||||
{(user?.canAccessToAPI || data?.rol === "admin") && <GenerateToken />}
|
{(user?.canAccessToAPI || data?.rol === "admin") && <GenerateToken />}
|
||||||
|
|
||||||
|
{isCloud && <RemoveSelfAccount />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -33,7 +38,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="Profile">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="Registry">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="Server">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="Servers">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="SSH Keys">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default Page;
|
|||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
Page.getLayout = (page: ReactElement) => {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout tab={"settings"}>
|
<DashboardLayout tab={"settings"} metaName="Users">
|
||||||
<SettingsLayout>{page}</SettingsLayout>
|
<SettingsLayout>{page}</SettingsLayout>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ const Register = ({ isCloud }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data && (
|
{data?.type === "cloud" && (
|
||||||
<AlertBlock type="success" className="mx-4 my-2">
|
<AlertBlock type="success" className="mx-4 my-2">
|
||||||
<span>
|
<span>
|
||||||
Registration succesfuly, Please check your inbox or spam
|
Registration succesfuly, Please check your inbox or spam
|
||||||
|
|||||||
@@ -18,6 +18,14 @@
|
|||||||
"settings.server.webServer.server.label": "Server",
|
"settings.server.webServer.server.label": "Server",
|
||||||
"settings.server.webServer.traefik.label": "Traefik",
|
"settings.server.webServer.traefik.label": "Traefik",
|
||||||
"settings.server.webServer.traefik.modifyEnv": "Modify Env",
|
"settings.server.webServer.traefik.modifyEnv": "Modify Env",
|
||||||
|
"settings.server.webServer.traefik.managePorts": "Additional Ports",
|
||||||
|
"settings.server.webServer.traefik.managePortsDescription": "Add or remove additional ports for Traefik",
|
||||||
|
"settings.server.webServer.traefik.targetPort": "Target Port",
|
||||||
|
"settings.server.webServer.traefik.publishedPort": "Published Port",
|
||||||
|
"settings.server.webServer.traefik.addPort": "Add Port",
|
||||||
|
"settings.server.webServer.traefik.portsUpdated": "Ports updated successfully",
|
||||||
|
"settings.server.webServer.traefik.portsUpdateError": "Failed to update ports",
|
||||||
|
"settings.server.webServer.traefik.publishMode": "Publish Mode",
|
||||||
"settings.server.webServer.storage.label": "Space",
|
"settings.server.webServer.storage.label": "Space",
|
||||||
"settings.server.webServer.storage.cleanUnusedImages": "Clean unused images",
|
"settings.server.webServer.storage.cleanUnusedImages": "Clean unused images",
|
||||||
"settings.server.webServer.storage.cleanUnusedVolumes": "Clean unused volumes",
|
"settings.server.webServer.storage.cleanUnusedVolumes": "Clean unused volumes",
|
||||||
|
|||||||
1
apps/dokploy/public/locales/it/common.json
Normal file
1
apps/dokploy/public/locales/it/common.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user