Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu
6eaafcb572 refactor: wip 2024-12-08 22:08:27 -06:00
168 changed files with 1807 additions and 15635 deletions

View File

@@ -99,14 +99,14 @@ workflows:
only: only:
- main - main
- canary - canary
- fix/nixpacks-version - 379-preview-deployment
- build-arm64: - build-arm64:
filters: filters:
branches: branches:
only: only:
- main - main
- canary - canary
- fix/nixpacks-version - 379-preview-deployment
- combine-manifests: - combine-manifests:
requires: requires:
- build-amd64 - build-amd64
@@ -116,4 +116,4 @@ workflows:
only: only:
- main - main
- canary - canary
- fix/nixpacks-version - 379-preview-deployment

1
.gitignore vendored
View File

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

View File

@@ -48,8 +48,6 @@ 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 \

View File

@@ -87,7 +87,7 @@ export const Login2FA = ({ authId }: Props) => {
</span> </span>
</div> </div>
)} )}
<CardTitle className="text-xl font-bold">2FA Login</CardTitle> <CardTitle className="text-xl font-bold">2FA Setup</CardTitle>
<FormField <FormField
control={form.control} control={form.control}

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,7 +19,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, 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";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -103,26 +102,9 @@ export const DeleteApplication = ({ applicationId }: Props) => {
name="projectName" name="projectName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex items-center gap-2"> <FormLabel>
<span> To confirm, type "{data?.name}/{data?.appName}" in the box
To confirm, type{" "} below
<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}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input

View File

@@ -6,10 +6,6 @@ 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;
@@ -19,26 +15,9 @@ 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 [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const endOfLogsRef = useRef<HTMLDivElement>(null);
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;
@@ -69,20 +48,13 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
}; };
}, [logPath, open]); }, [logPath, open]);
const scrollToBottom = () => {
useEffect(() => { endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
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
@@ -104,27 +76,17 @@ 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 | <Badge variant="blank" className="text-xs">{filteredLogs.length} lines</Badge> See all the details of this deployment
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div <div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
ref={scrollRef} <code>
onScroll={handleScroll} <pre className="whitespace-pre-wrap break-words">
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar" {data || "Loading..."}
> { </pre>
filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => ( <div ref={endOfLogsRef} />
<TerminalLine </code>
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>

View File

@@ -11,7 +11,6 @@ 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 {
@@ -19,7 +18,6 @@ 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,
@@ -53,9 +51,6 @@ 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(() => {

View File

@@ -90,6 +90,7 @@ 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>

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,7 +19,6 @@ 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";
@@ -102,27 +100,10 @@ export const DeleteCompose = ({ composeId }: Props) => {
name="projectName" name="projectName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex items-center gap-2"> <FormLabel>
<span> To confirm, type "{data?.name}/{data?.appName}" in the box
To confirm, type{" "} below
<Badge </FormLabel>{" "}
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}&nbsp;
<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"

View File

@@ -6,11 +6,6 @@ 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;
@@ -25,26 +20,9 @@ export const ShowDeploymentCompose = ({
serverId, serverId,
}: Props) => { }: Props) => {
const [data, setData] = useState(""); const [data, setData] = useState("");
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const endOfLogsRef = useRef<HTMLDivElement>(null);
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;
@@ -76,19 +54,13 @@ export const ShowDeploymentCompose = ({
}; };
}, [logPath, open]); }, [logPath, open]);
const scrollToBottom = () => {
useEffect(() => { endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
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
@@ -106,35 +78,21 @@ export const ShowDeploymentCompose = ({
} }
}} }}
> >
<DialogContent className={"sm:max-w-5xl max-h-screen"}> <DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogHeader> <DialogHeader>
<DialogTitle>Deployment</DialogTitle> <DialogTitle>Deployment</DialogTitle>
<DialogDescription> <DialogDescription>
See all the details of this deployment | <Badge variant="blank" className="text-xs">{filteredLogs.length} lines</Badge> See all the details of this deployment
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div <div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
ref={scrollRef} <code>
onScroll={handleScroll} <pre className="whitespace-pre-wrap break-words">
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar" {data || "Loading..."}
> </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>

View File

@@ -11,7 +11,6 @@ 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 {
@@ -19,7 +18,6 @@ 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,
@@ -50,15 +48,9 @@ export const DeployCompose = ({ composeId }: Props) => {
await refetch(); await refetch();
await deploy({ await deploy({
composeId, composeId,
}) }).catch(() => {
.then(async () => { toast.error("Error to deploy Compose");
router.push( });
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`
);
})
.catch(() => {
toast.error("Error to deploy Compose");
});
await refetch(); await refetch();
}} }}

View File

@@ -0,0 +1,161 @@
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>
);
};

View File

@@ -96,6 +96,7 @@ 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>

View File

@@ -1,4 +1,3 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -35,7 +34,7 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
View Config View Config
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<DialogContent className={"w-full md:w-[70vw] min-w-[70vw]"}> <DialogContent className={"w-full md:w-[70vw] max-w-max"}>
<DialogHeader> <DialogHeader>
<DialogTitle>Container Config</DialogTitle> <DialogTitle>Container Config</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -45,13 +44,7 @@ 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">
<CodeEditor {JSON.stringify(data, null, 2)}
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre> </pre>
</code> </code>
</div> </div>

View File

@@ -1,290 +1,114 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; import { Label } from "@/components/ui/label";
import { Download as DownloadIcon, Loader2 } from "lucide-react"; import { Terminal } from "@xterm/xterm";
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { LineCountFilter } from "./line-count-filter"; import { FitAddon } from "xterm-addon-fit";
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter"; import "@xterm/xterm/css/xterm.css";
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 priorities = [ export const DockerLogsId: React.FC<Props> = ({
{ id,
label: "Info", containerId,
value: "info", serverId,
}, }) => {
{ const [term, setTerm] = React.useState<Terminal>();
label: "Success", const [lines, setLines] = React.useState<number>(40);
value: "success", const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
},
{
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) return; // if (containerId === "select-a-container") {
// return;
let isCurrentConnection = true; // }
let noDataTimeout: NodeJS.Timeout; const container = document.getElementById(id);
setIsLoading(true); if (container) {
setRawLogs(""); container.innerHTML = "";
setFilteredLogs([]);
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const params = new globalThis.URLSearchParams({
containerId,
tail: lines.toString(),
since,
search,
});
if (serverId) {
params.append("serverId", serverId);
} }
const wsUrl = `${protocol}//${ if (wsRef.current) {
window.location.host if (wsRef.current.readyState === WebSocket.OPEN) {
}/docker-container-logs?${params.toString()}`; wsRef.current.close();
console.log("Connecting to WebSocket:", wsUrl);
const ws = new WebSocket(wsUrl);
const resetNoDataTimeout = () => {
if (noDataTimeout) clearTimeout(noDataTimeout);
noDataTimeout = setTimeout(() => {
if (isCurrentConnection) {
setIsLoading(false);
}
}, 2000); // Wait 2 seconds for data before showing "No logs found"
};
ws.onopen = () => {
if (!isCurrentConnection) {
ws.close();
return;
} }
console.log("WebSocket connected"); wsRef.current = null;
resetNoDataTimeout(); }
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 wsUrl = `${protocol}//${window.location.host}/docker-container-logs?containerId=${containerId}&tail=${lines}${serverId ? `&serverId=${serverId}` : ""}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
const fitAddon = new FitAddon();
termi.loadAddon(fitAddon);
// @ts-ignore
termi.open(container);
fitAddon.fit();
termi.focus();
setTerm(termi);
ws.onerror = (error) => {
console.error("WebSocket error: ", error);
}; };
ws.onmessage = (e) => { ws.onmessage = (e) => {
if (!isCurrentConnection) return; termi.write(e.data);
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) => {
if (!isCurrentConnection) return; console.log(e.reason);
console.log("WebSocket closed:", e.reason);
setIsLoading(false);
if (noDataTimeout) clearTimeout(noDataTimeout);
};
termi.write(`Connection closed!\nReason: ${e.reason}\n`);
wsRef.current = null;
};
return () => { return () => {
isCurrentConnection = false; if (wsRef.current?.readyState === WebSocket.OPEN) {
if (noDataTimeout) clearTimeout(noDataTimeout);
if (ws.readyState === WebSocket.OPEN) {
ws.close(); ws.close();
wsRef.current = null;
} }
}; };
}, [containerId, serverId, lines, search, since]); }, [lines, containerId]);
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(() => {
setRawLogs(""); term?.clear();
setFilteredLogs([]); }, [lines, term]);
}, [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="rounded-lg overflow-hidden"> <div className="flex flex-col gap-2">
<div className="space-y-4"> <Label>
<div className="flex flex-wrap justify-between items-start sm:items-center gap-4"> <span>Number of lines to show</span>
<div className="flex flex-wrap gap-4"> </Label>
<LineCountFilter value={lines} onValueChange={handleLines} /> <Input
type="text"
placeholder="Number of lines to show (Defaults to 35)"
value={lines}
onChange={(e) => {
setLines(Number(e.target.value) || 1);
}}
/>
</div>
<SinceLogsFilter <div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
value={since} <div id={id} />
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>
); );

View File

@@ -1,173 +0,0 @@
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;

View File

@@ -46,7 +46,11 @@ 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 containerId={containerId || ""} serverId={serverId} /> <DockerLogsId
id="terminal"
containerId={containerId || ""}
serverId={serverId}
/>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -1,125 +0,0 @@
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>
);
}

View File

@@ -1,170 +0,0 @@
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>
);
}

View File

@@ -1,131 +0,0 @@
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>
);
}

View File

@@ -1,246 +0,0 @@
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;
}

View File

@@ -4,7 +4,6 @@ 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;
@@ -19,7 +18,6 @@ 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) {
@@ -27,12 +25,13 @@ 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: resolvedTheme === "light" ? "#000000" : "transparent", cursor: "transparent",
background: "rgba(0, 0, 0, 0)", background: "rgba(0, 0, 0, 0)",
foreground: "currentColor",
}, },
}); });
const addonFit = new FitAddon(); const addonFit = new FitAddon();
@@ -46,7 +45,6 @@ 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();
@@ -68,7 +66,7 @@ export const DockerTerminal: React.FC<Props> = ({
</TabsList> </TabsList>
</Tabs> </Tabs>
</div> </div>
<div className="w-full h-full rounded-lg p-2 bg-transparent border"> <div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
<div id={id} ref={termRef} /> <div id={id} ref={termRef} />
</div> </div>
</div> </div>

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,7 +19,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, 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";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -100,26 +99,9 @@ export const DeleteMariadb = ({ mariadbId }: Props) => {
name="projectName" name="projectName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex items-center gap-2"> <FormLabel>
<span> To confirm, type "{data?.name}/{data?.appName}" in the box
To confirm, type{" "} below
<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}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,7 +19,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, 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";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -99,26 +98,9 @@ export const DeleteMongo = ({ mongoId }: Props) => {
name="projectName" name="projectName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex items-center gap-2"> <FormLabel>
<span> To confirm, type "{data?.name}/{data?.appName}" in the box
To confirm, type{" "} below
<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}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,7 +19,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, 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";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -98,26 +97,9 @@ export const DeleteMysql = ({ mysqlId }: Props) => {
name="projectName" name="projectName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex items-center gap-2"> <FormLabel>
<span> To confirm, type "{data?.name}/{data?.appName}" in the box
To confirm, type{" "} below
<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}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,7 +19,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, 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";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -101,26 +100,9 @@ export const DeletePostgres = ({ postgresId }: Props) => {
name="projectName" name="projectName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex items-center gap-2"> <FormLabel>
<span> To confirm, type "{data?.name}/{data?.appName}" in the box
To confirm, type{" "} below
<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}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input

View File

@@ -92,8 +92,7 @@ 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;
}) || []; }) || [];

View File

@@ -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,257 +38,253 @@ 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"}://${ href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
domain.host >
}${domain.path}`} <span>{domain.host}</span>
> <ExternalLink className="size-4 shrink-0" />
<span>{domain.host}</span> </Link>
<ExternalLink className="size-4 shrink-0" /> </DropdownMenuItem>
</Link> ))}
</DropdownMenuItem> </DropdownMenuGroup>
))} </Fragment>
</DropdownMenuGroup> ))}
</Fragment> </DropdownMenuGroup>
))} ) : 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={`${ href={`${flattedDomains[0].https ? "https" : "http"}://${flattedDomains[0].host}${flattedDomains[0].path}`}
flattedDomains[0].https ? "https" : "http" target="_blank"
}://${flattedDomains[0].host}${flattedDomains[0].path}`} >
target="_blank" <ExternalLinkIcon className="size-3.5" />
> </Link>
<ExternalLinkIcon className="size-3.5" /> </Button>
</Link> ) : null}
</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>
</> </>
); );
}; };

View File

@@ -1,4 +1,3 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,7 +19,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, 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";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -98,26 +97,9 @@ export const DeleteRedis = ({ redisId }: Props) => {
name="projectName" name="projectName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel className="flex items-center gap-2"> <FormLabel>
<span> To confirm, type "{data?.name}/{data?.appName}" in the box
To confirm, type{" "} below
<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}&nbsp;
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
</Badge>{" "}
in the box below:
</span>
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input

View File

@@ -1,189 +0,0 @@
"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>
);
};

View File

@@ -6,144 +6,13 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { AlertCircle, Link, ShieldCheck } from "lucide-react"; import { 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">
@@ -154,7 +23,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 ? ( {data?.length === 0 ? (
<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">
@@ -166,53 +35,21 @@ 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((certificate, index) => { {data?.map((destination, index) => (
const expiration = getExpirationStatus( <div
certificate.certificateData, key={destination.certificateId}
); className="flex items-center justify-between border p-4 rounded-lg"
const chainInfo = getCertificateChainInfo( >
certificate.certificateData, <span className="text-sm text-muted-foreground">
); {index + 1}. {destination.name}
return ( </span>
<div <div className="flex flex-row gap-3">
key={certificate.certificateId} <DeleteCertificate
className="flex flex-col border p-4 rounded-lg space-y-2" certificateId={destination.certificateId}
> />
<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 />

View File

@@ -1,4 +1,3 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -34,13 +33,7 @@ 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">
<CodeEditor {JSON.stringify(data, null, 2)}
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre> </pre>
</code> </code>
</div> </div>

View File

@@ -107,24 +107,7 @@ export const AddGithubProvider = () => {
/> />
<br /> <br />
<div className="flex w-full items-center justify-between"> <div className="flex w-full justify-end">
<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"

View File

@@ -33,9 +33,6 @@ 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",
}), }),
@@ -65,22 +62,16 @@ 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]);
@@ -92,7 +83,6 @@ 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();
@@ -139,7 +129,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={`${gitlabUrl}/-/profile/applications`} href="https://gitlab.com/-/profile/applications"
target="_blank" target="_blank"
> >
<ExternalLink className="w-fit text-primary size-4" /> <ExternalLink className="w-fit text-primary size-4" />
@@ -179,20 +169,6 @@ 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"

View File

@@ -30,9 +30,6 @@ 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(),
}); });
@@ -43,7 +40,7 @@ interface Props {
} }
export const EditGitlabProvider = ({ gitlabId }: Props) => { export const EditGitlabProvider = ({ gitlabId }: Props) => {
const { data: gitlab, refetch } = api.gitlab.one.useQuery( const { data: gitlab } = api.gitlab.one.useQuery(
{ {
gitlabId, gitlabId,
}, },
@@ -60,7 +57,6 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
defaultValues: { defaultValues: {
groupName: "", groupName: "",
name: "", name: "",
gitlabUrl: "https://gitlab.com",
}, },
resolver: zodResolver(Schema), resolver: zodResolver(Schema),
}); });
@@ -71,7 +67,6 @@ 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]);
@@ -81,13 +76,11 @@ 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");
@@ -133,19 +126,6 @@ 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}

View File

@@ -23,16 +23,12 @@ export const ShowGitProviders = () => {
const url = useUrl(); const url = useUrl();
const getGitlabUrl = ( const getGitlabUrl = (clientId: string, gitlabId: string) => {
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 = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`; const authUrl = `https://gitlab.com/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
return authUrl; return authUrl;
}; };
@@ -146,7 +142,6 @@ 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({

View File

@@ -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 { Trash2 } from "lucide-react"; import { TrashIcon } from "lucide-react";
import React from "react"; import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -24,13 +24,8 @@ export const DeleteNotification = ({ notificationId }: Props) => {
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button variant="ghost" isLoading={isLoading}>
variant="ghost" <TrashIcon className="size-4 text-muted-foreground" />
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>

View File

@@ -40,58 +40,48 @@ 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-1 xl:grid-cols-2 gap-4"> <div className="grid lg:grid-cols-2 xl:grid-cols-3 gap-4">
{data?.map((notification, index) => ( {data?.map((notification, index) => (
<div <div
key={notification.notificationId} key={notification.notificationId}
className="flex items-center justify-between rounded-xl p-4 transition-colors dark:bg-zinc-900/50 hover:bg-zinc-900 border border-zinc-800/50" className="flex items-center justify-between border gap-2 p-3.5 rounded-lg"
> >
<div className="flex items-center gap-4"> <div className="flex flex-row gap-2 items-center w-full ">
{notification.notificationType === "slack" && ( {notification.notificationType === "slack" && (
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10"> <SlackIcon className="text-muted-foreground size-6 flex-shrink-0" />
<SlackIcon className="h-6 w-6 text-indigo-400" /> )}
</div> {notification.notificationType === "telegram" && (
)} <TelegramIcon className="text-muted-foreground size-8 flex-shrink-0" />
{notification.notificationType === "telegram" && ( )}
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-500/10"> {notification.notificationType === "discord" && (
<TelegramIcon className="h-6 w-6 text-indigo-400" /> <DiscordIcon className="text-muted-foreground size-7 flex-shrink-0" />
</div> )}
)} {notification.notificationType === "email" && (
{notification.notificationType === "discord" && ( <Mail
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10"> size={29}
<DiscordIcon className="h-6 w-6 text-indigo-400" /> className="text-muted-foreground size-6 flex-shrink-0"
</div> />
)} )}
{notification.notificationType === "email" && ( <span className="text-sm text-muted-foreground">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-500/10"> {notification.name}
<Mail className="h-6 w-6 text-indigo-400" /> </span>
</div> </div>
)}
<div className="flex flex-col"> <div className="flex flex-row gap-1 w-fit">
<span className="text-sm font-medium text-zinc-300"> <UpdateNotification
{notification.name} notificationId={notification.notificationId}
</span> />
<span className="text-xs font-medium text-muted-foreground"> <DeleteNotification
{notification.notificationType?.[0]?.toUpperCase() + notification.notificationType?.slice(1)} notification notificationId={notification.notificationId}
</span> />
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> ))}
<UpdateNotification </div>
notificationId={notification.notificationId} <div className="flex flex-col gap-4 justify-end w-full items-end">
/> <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>

View File

@@ -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, Pen } from "lucide-react"; import { Mail, PenBoxIcon } 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,10 +218,8 @@ 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">
size="icon" <PenBoxIcon className="size-4 text-muted-foreground" />
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">

View File

@@ -26,12 +26,10 @@ 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(),
}); });
@@ -54,8 +52,7 @@ 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, isError, error } = const { mutateAsync, isLoading } = api.auth.update.useMutation();
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);
@@ -71,7 +68,6 @@ export const ProfileForm = () => {
email: data?.email || "", email: data?.email || "",
password: "", password: "",
image: data?.image || "", image: data?.image || "",
currentPassword: "",
}, },
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
}); });
@@ -82,7 +78,6 @@ export const ProfileForm = () => {
email: data?.email || "", email: data?.email || "",
password: "", password: "",
image: data?.image || "", image: data?.image || "",
currentPassword: "",
}); });
if (data.email) { if (data.email) {
@@ -99,12 +94,10 @@ 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");
@@ -123,8 +116,6 @@ 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">
@@ -144,24 +135,6 @@ 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"

View File

@@ -1,130 +0,0 @@
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>
);
};

View File

@@ -26,7 +26,6 @@ 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;
@@ -129,14 +128,6 @@ 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>

View File

@@ -1,167 +0,0 @@
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>
);
};

View File

@@ -262,16 +262,16 @@ export function StatusRow({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showIcon ? ( {showIcon ? (
<> <>
<span
className={`text-sm ${isEnabled ? "text-green-500" : "text-red-500"}`}
>
{description || (isEnabled ? "Installed" : "Not Installed")}
</span>
{isEnabled ? ( {isEnabled ? (
<CheckCircle2 className="size-4 text-green-500" /> <CheckCircle2 className="size-4 text-green-500" />
) : ( ) : (
<XCircle className="size-4 text-red-500" /> <XCircle className="size-4 text-red-500" />
)} )}
<span
className={`text-sm ${isEnabled ? "text-green-500" : "text-red-500"}`}
>
{description || (isEnabled ? "Installed" : "Not Installed")}
</span>
</> </>
) : ( ) : (
<span className="text-sm text-muted-foreground">{value}</span> <span className="text-sm text-muted-foreground">{value}</span>

View File

@@ -1,233 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { 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>
);
};

View File

@@ -32,10 +32,8 @@ 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;
@@ -91,18 +89,12 @@ export const SetupServer = ({ serverId }: Props) => {
</AlertBlock> </AlertBlock>
</div> </div>
) : ( ) : (
<div id="hook-form-add-gitlab" className="grid w-full gap-4"> <div id="hook-form-add-gitlab" className="grid w-full gap-1">
<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-5 w-[700px]"> <TabsList className="grid grid-cols-4 w-[600px]">
<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
@@ -147,7 +139,7 @@ export const SetupServer = ({ serverId }: Props) => {
Automatic process Automatic process
</span> </span>
<Link <Link
href="https://docs.dokploy.com/docs/core/multi-server/instructions#requirements" href="https://docs.dokploy.com/en/docs/core/get-started/introduction"
target="_blank" target="_blank"
className="text-primary flex flex-row gap-2" className="text-primary flex flex-row gap-2"
> >
@@ -206,28 +198,6 @@ 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">
@@ -244,29 +214,24 @@ export const SetupServer = ({ serverId }: Props) => {
See all the 5 Server Setup See all the 5 Server Setup
</CardDescription> </CardDescription>
</div> </div>
<div className="flex flex-row gap-2"> <DialogAction
<EditScript serverId={server?.serverId || ""} /> title={"Setup Server?"}
<DialogAction description="This will setup the server and all associated data"
title={"Setup Server?"} onClick={async () => {
description="This will setup the server and all associated data" await mutateAsync({
onClick={async () => { serverId: server?.serverId || "",
await mutateAsync({ })
serverId: server?.serverId || "", .then(async () => {
refetch();
toast.success("Server setup successfully");
}) })
.then(async () => { .catch(() => {
refetch(); toast.error("Error configuring server");
toast.success("Server setup successfully"); });
}) }}
.catch(() => { >
toast.error("Error configuring server"); <Button isLoading={isLoading}>Setup Server</Button>
}); </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">
@@ -338,14 +303,6 @@ 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"

View File

@@ -31,12 +31,8 @@ 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();
@@ -46,26 +42,12 @@ 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 className="flex flex-col gap-2"> <div>
<div> <h1 className="text-2xl font-bold">Servers</h1>
<h1 className="text-2xl font-bold">Servers</h1> <p className="text-muted-foreground">
<p className="text-muted-foreground"> Add servers to deploy your applications remotely.
Add servers to deploy your applications remotely. </p>
</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 && (
@@ -118,9 +100,7 @@ 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> <TableCaption>See all servers</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>

View File

@@ -1,5 +1,4 @@
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,
@@ -9,8 +8,9 @@ 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 { useState } from "react";
import { StatusRow } from "./gpu-support"; import { StatusRow } from "./gpu-support";
import { Button } from "@/components/ui/button";
import { useState } from "react";
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,31 +113,16 @@ export const ValidateServer = ({ serverId }: Props) => {
} }
/> />
<StatusRow <StatusRow
label="Docker Swarm Initialized" label="Dokploy Network Installed"
isEnabled={data?.isSwarmInstalled} isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/> />
<StatusRow <StatusRow
label="Dokploy Network Created" label="Swarm Installed"
isEnabled={data?.isDokployNetworkInstalled} isEnabled={data?.isSwarmInstalled}
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>

View File

@@ -1,284 +0,0 @@
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>
);
};

View File

@@ -1,152 +0,0 @@
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>
);
};

View File

@@ -1,163 +0,0 @@
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>
);
};

View File

@@ -1,189 +0,0 @@
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>
);
};

View File

@@ -1,411 +0,0 @@
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>
);
};

View File

@@ -1,230 +0,0 @@
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>
</>
);
};

View File

@@ -91,7 +91,11 @@ export const ShowModalLogs = ({ appName, children, serverId }: Props) => {
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<DockerLogsId containerId={containerId || ""} serverId={serverId} /> <DockerLogsId
id="terminal"
containerId={containerId || ""}
serverId={serverId}
/>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -4,7 +4,6 @@ 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;
@@ -13,7 +12,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) {
@@ -21,12 +20,13 @@ 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: resolvedTheme === "light" ? "#000000" : "transparent", cursor: "transparent",
background: "rgba(0, 0, 0, 0)", background: "#19191A",
foreground: "currentColor",
}, },
}); });
const addonFit = new FitAddon(); const addonFit = new FitAddon();
@@ -40,7 +40,6 @@ 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();
@@ -51,7 +50,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-transparent border rounded-lg p-2 "> <div className="w-full h-full bg-input rounded-lg p-2 ">
<div id={id} ref={termRef} className="rounded-xl" /> <div id={id} ref={termRef} className="rounded-xl" />
</div> </div>
</div> </div>

View File

@@ -1,28 +0,0 @@
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>
);
};

View File

@@ -3,224 +3,91 @@ 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 { import { RefreshCcw } from "lucide-react";
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 [hasCheckedUpdate, setHasCheckedUpdate] = useState(false); const [isUpdateAvailable, setIsUpdateAvailable] = useState<null | boolean>(
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); null,
const { mutateAsync: getUpdateData, isLoading } = );
api.settings.getUpdateData.useMutation(); const { mutateAsync: checkAndUpdateImage, isLoading } =
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); api.settings.checkAndUpdateImage.useMutation();
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" className="gap-2"> <Button variant="secondary">
<Sparkles className="h-4 w-4" /> <RefreshCcw className="h-4 w-4" />
Updates Updates
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-lg p-6"> <DialogContent className="sm:m:max-w-lg ">
<div className="flex items-center justify-between mb-8"> <DialogHeader>
<DialogTitle className="text-2xl font-semibold"> <DialogTitle>Web Server Update</DialogTitle>
Web Server Update <DialogDescription>
</DialogTitle> Check new releases and update your dokploy
{dokployVersion && ( </DialogDescription>
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted"> </DialogHeader>
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{dokployVersion} | {releaseTag}
</span>
</div>
)}
</div>
{/* Initial state */} <div className="flex flex-col gap-4">
{!hasCheckedUpdate && ( <span className="text-sm text-muted-foreground">
<div className="mb-8"> We suggest to update your dokploy to the latest version only if you:
<p className="text text-muted-foreground"> </span>
Check for new releases and update Dokploy. <ul className="list-disc list-inside text-sm text-muted-foreground">
<br /> <li>Want to try the latest features</li>
<br /> <li>Some bug that is blocking to use some features</li>
We recommend checking for updates regularly to ensure you have the </ul>
latest features and security improvements. <AlertBlock type="info">
</p> We recommend checking the latest version for any breaking changes
</div> before updating. Go to{" "}
)} <Link
href="https://github.com/Dokploy/dokploy/releases"
target="_blank"
className="text-foreground"
>
Dokploy Releases
</Link>{" "}
to check the latest version.
</AlertBlock>
{/* Update available state */} <div className="w-full flex flex-col gap-4">
{isUpdateAvailable && latestVersion && ( {isUpdateAvailable === false && (
<div className="mb-8"> <div className="flex flex-col items-center gap-3">
<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"> <RefreshCcw className="size-6 self-center text-muted-foreground" />
<div className="flex items-center gap-1.5"> <span className="text-sm text-muted-foreground">
<span className="flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</span>
<Download className="h-4 w-4 text-emerald-400" />
<span className="text font-medium text-emerald-400 ">
New version available:
</span>
</div>
<span className="text font-semibold text-emerald-300">
{latestVersion}
</span>
</div>
<div className="space-y-4 text-muted-foreground">
<p className="text">
A new version of the server software is available. Consider
updating if you:
</p>
<ul className="space-y-3">
<li className="flex items-start gap-2">
<Stars className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
<span className="text">
Want to access the latest features and improvements
</span>
</li>
<li className="flex items-start gap-2">
<Bug className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
<span className="text">
Are experiencing issues that may be resolved in the new
version
</span>
</li>
</ul>
</div>
</div>
)}
{/* Up to date state */}
{hasCheckedUpdate && !isUpdateAvailable && !isLoading && (
<div className="mb-8">
<div className="flex flex-col items-center gap-6 mb-6">
<div className="rounded-full p-4 bg-emerald-400/40">
<Sparkles className="h-8 w-8 text-emerald-400" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">
You are using the latest version You are using the latest version
</h3> </span>
<p className="text text-muted-foreground">
Your server is up to date with all the latest features and
security improvements.
</p>
</div> </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
variant="secondary" className="w-full"
onClick={handleCheckUpdates} onClick={async () => {
disabled={isLoading} await checkAndUpdateImage()
.then(async (e) => {
setIsUpdateAvailable(e);
})
.catch(() => {
setIsUpdateAvailable(false);
toast.error("Error to check updates");
});
toast.success("Check updates");
}}
isLoading={isLoading}
> >
{isLoading ? ( Check Updates
<>
<RefreshCcw className="h-4 w-4 animate-spin" />
Checking for updates
</>
) : (
<>
<RefreshCcw className="h-4 w-4" />
Check for updates
</>
)}
</Button> </Button>
)} )}
</div> </div>
@@ -229,5 +96,3 @@ export const UpdateServer = () => {
</Dialog> </Dialog>
); );
}; };
export default UpdateServer;

View File

@@ -11,53 +11,24 @@ 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";
interface Props { export const UpdateWebServer = () => {
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={isNavbar ? "outline" : "secondary"} variant="secondary"
isLoading={isLoading} isLoading={isLoading}
> >
{!isLoading && <HardDriveDownload className="h-4 w-4" />} <span className="absolute -right-1 -top-2 flex h-3 w-3">
{!isLoading && ( <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="absolute -right-1 -top-2 flex h-3 w-3"> <span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" /> </span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" /> Update Server
</span>
)}
{isLoading ? "Updating..." : buttonLabel}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
@@ -65,12 +36,19 @@ export const UpdateWebServer = ({ isNavbar }: Props) => {
<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. The page will be reloaded once the update is finished. new version.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>Confirm</AlertDialogAction> <AlertDialogAction
onClick={async () => {
await updateServer();
toast.success("Please reload the browser to see the changes");
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@@ -1,34 +1,25 @@
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, metaName }: Props) => { export const DashboardLayout = ({ children, tab }: Props) => {
return ( return (
<> <div>
<Head> <div
<title> className="bg-radial relative flex flex-col bg-background min-h-screen w-full"
{metaName ?? tab.charAt(0).toUpperCase() + tab.slice(1)} | Dokploy id="app-container"
</title> >
</Head> <Navbar />
<div> <main className="pt-6 flex w-full flex-col items-center">
<div <div className="w-full max-w-8xl px-4 lg:px-8">
className="bg-radial relative flex flex-col bg-background min-h-screen w-full" <NavigationTabs tab={tab}>{children}</NavigationTabs>
id="app-container" </div>
> </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>
); );
}; };

View File

@@ -12,16 +12,11 @@ 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();
@@ -34,59 +29,6 @@ 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">
@@ -101,11 +43,6 @@ export const Navbar = () => {
</span> </span>
</Link> </Link>
</div> </div>
{isUpdateAvailable && (
<div>
<UpdateWebServer isNavbar />
</div>
)}
<Link <Link
className={buttonVariants({ className={buttonVariants({
variant: "outline", variant: "outline",

View File

@@ -77,7 +77,7 @@ export const SettingsLayout = ({ children }: Props) => {
{ {
title: "Registry", title: "Registry",
label: "", label: "",
icon: GalleryVerticalEnd, icon: ListMusic,
href: "/dashboard/settings/registry", href: "/dashboard/settings/registry",
}, },
@@ -150,7 +150,6 @@ import {
BoxesIcon, BoxesIcon,
CreditCardIcon, CreditCardIcon,
Database, Database,
GalleryVerticalEnd,
GitBranch, GitBranch,
KeyIcon, KeyIcon,
KeyRound, KeyRound,

View File

@@ -2,9 +2,7 @@ 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";
@@ -12,16 +10,14 @@ 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" | "shell"; language?: "yaml" | "json" | "properties";
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();
@@ -29,7 +25,7 @@ export const CodeEditor = ({
<div className={cn("relative overflow-auto", wrapperClassName)}> <div className={cn("relative overflow-auto", wrapperClassName)}>
<CodeMirror <CodeMirror
basicSetup={{ basicSetup={{
lineNumbers, lineNumbers: true,
foldGutter: true, foldGutter: true,
highlightSelectionMatches: true, highlightSelectionMatches: true,
highlightActiveLine: !props.disabled, highlightActiveLine: !props.disabled,
@@ -41,9 +37,7 @@ export const CodeEditor = ({
? yaml() ? yaml()
: language === "json" : language === "json"
? json() ? json()
: language === "shell" : StreamLanguage.define(properties),
? StreamLanguage.define(shell)
: StreamLanguage.define(properties),
props.lineWrapping ? EditorView.lineWrapping : [], props.lineWrapping ? EditorView.lineWrapping : [],
]} ]}
{...props} {...props}

View File

@@ -14,16 +14,6 @@ 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",
}, },
}, },

View File

@@ -62,7 +62,6 @@ 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}

View File

@@ -9,8 +9,6 @@ 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>
@@ -27,10 +25,4 @@ const TooltipContent = React.forwardRef<
)); ));
TooltipContent.displayName = TooltipPrimitive.Content.displayName; TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
TooltipPortal,
};

View File

@@ -1 +0,0 @@
ALTER TABLE "gitlab" ADD COLUMN "gitlabUrl" text DEFAULT 'https://gitlab.com' NOT NULL;

View File

@@ -1 +0,0 @@
ALTER TABLE "server" ADD COLUMN "command" text DEFAULT '' NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -351,20 +351,6 @@
"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
} }
] ]
} }

View File

@@ -11,8 +11,6 @@ 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;

View File

@@ -0,0 +1,23 @@
/** @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,
},
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.15.1", "version": "v0.14.0",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -35,8 +35,6 @@
"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",

View File

@@ -1,6 +1,5 @@
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";
@@ -34,10 +33,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>
@@ -57,7 +56,6 @@ const MyApp = ({
forcedTheme={Component.theme} forcedTheme={Component.theme}
> >
<Toaster richColors /> <Toaster richColors />
<SearchCommand />
{getLayout(<Component {...pageProps} />)} {getLayout(<Component {...pageProps} />)}
</ThemeProvider> </ThemeProvider>
</> </>
@@ -65,13 +63,20 @@ const MyApp = ({
}; };
export default api.withTRPC( export default api.withTRPC(
appWithTranslation(MyApp, { appWithTranslation(
i18n: { MyApp,
defaultLocale: "en", // keep this in sync with next-i18next.config.js
locales: Object.values(Languages), // 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.
localeDetection: false, // Add the config here is due to the issue: https://github.com/i18next/next-i18next/issues/2259
// 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,
}),
); );

View File

@@ -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(`${gitlab.gitlabUrl}/oauth/token`, { const response = await fetch("https://gitlab.com/oauth/token", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",

View File

@@ -39,7 +39,6 @@ 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";
@@ -190,9 +189,6 @@ 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>

View File

@@ -41,10 +41,9 @@ 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, useEffect, type ReactElement } from "react"; import React, { useState, type ReactElement } from "react";
import superjson from "superjson"; import superjson from "superjson";
type TabState = type TabState =
@@ -62,14 +61,7 @@ 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, setTab] = useState<TabState>(activeTab); const [tab, setSab] = 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 },
{ {
@@ -109,11 +101,6 @@ 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">
@@ -198,9 +185,9 @@ const Service = (
defaultValue="general" defaultValue="general"
className="w-full" className="w-full"
onValueChange={(e) => { onValueChange={(e) => {
setTab(e as TabState); setSab(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); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<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">

View File

@@ -5,6 +5,7 @@ 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";
@@ -35,10 +36,9 @@ 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, useEffect, type ReactElement } from "react"; import React, { useState, type ReactElement } from "react";
import superjson from "superjson"; import superjson from "superjson";
type TabState = type TabState =
@@ -55,14 +55,7 @@ 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, setTab] = useState<TabState>(activeTab); const [tab, setSab] = 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 },
{ {
@@ -102,11 +95,6 @@ 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">
@@ -190,9 +178,9 @@ const Service = (
defaultValue="general" defaultValue="general"
className="w-full" className="w-full"
onValueChange={(e) => { onValueChange={(e) => {
setTab(e as TabState); setSab(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); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<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">
@@ -219,6 +207,7 @@ 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) && (

View File

@@ -35,7 +35,6 @@ 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,11 +82,6 @@ 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">

View File

@@ -35,7 +35,6 @@ 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";
@@ -84,11 +83,6 @@ 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">

View File

@@ -35,7 +35,6 @@ 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,11 +81,6 @@ 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">

View File

@@ -35,7 +35,6 @@ 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,11 +82,6 @@ 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">

View File

@@ -34,7 +34,6 @@ 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,11 +81,6 @@ 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">

View File

@@ -21,7 +21,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="Appearance"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -16,7 +16,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="Billing"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -19,7 +19,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="Certificates"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="Nodes"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="S3 Destinations"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="Git Providers"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="Notifications"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -1,6 +1,5 @@
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";
@@ -22,14 +21,10 @@ 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>
); );
}; };
@@ -38,7 +33,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="Profile"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="Registry"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -23,7 +23,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="Server"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="Servers"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="SSH Keys"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -20,7 +20,7 @@ export default Page;
Page.getLayout = (page: ReactElement) => { Page.getLayout = (page: ReactElement) => {
return ( return (
<DashboardLayout tab={"settings"} metaName="Users"> <DashboardLayout tab={"settings"}>
<SettingsLayout>{page}</SettingsLayout> <SettingsLayout>{page}</SettingsLayout>
</DashboardLayout> </DashboardLayout>
); );

View File

@@ -134,7 +134,7 @@ const Register = ({ isCloud }: Props) => {
</span> </span>
</div> </div>
)} )}
{data?.type === "cloud" && ( {data && (
<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

View File

@@ -18,14 +18,6 @@
"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",

View File

@@ -1 +0,0 @@
{}

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