Merge branch 'canary' into feat/mongo-replica-sets

This commit is contained in:
Mauricio Siu
2024-12-25 03:27:00 -06:00
126 changed files with 9622 additions and 1542 deletions

View File

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

View File

@@ -1,3 +1,5 @@
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -5,11 +7,10 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { TerminalLine } from "../../docker/logs/terminal-line"; import { TerminalLine } from "../../docker/logs/terminal-line";
import { LogLine, parseLogs } from "../../docker/logs/utils"; import { type 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 +20,26 @@ interface Props {
} }
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => { export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
const [data, setData] = useState(""); const [data, setData] = useState("");
const [showExtraLogs, setShowExtraLogs] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => { const scrollToBottom = () => {
if (autoScroll && scrollRef.current) { if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}; };
const handleScroll = () => { const handleScroll = () => {
if (!scrollRef.current) return; if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom); setAutoScroll(isAtBottom);
}; };
useEffect(() => { useEffect(() => {
if (!open || !logPath) return; if (!open || !logPath) return;
@@ -69,20 +70,34 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
}; };
}, [logPath, open]); }, [logPath, open]);
useEffect(() => { useEffect(() => {
const logs = parseLogs(data); const logs = parseLogs(data);
setFilteredLogs(logs); let filteredLogsResult = logs;
}, [data]); if (serverId) {
let hideSubsequentLogs = false;
filteredLogsResult = logs.filter((log) => {
if (
log.message.includes(
"===================================EXTRA LOGS============================================",
)
) {
hideSubsequentLogs = true;
return showExtraLogs;
}
return showExtraLogs ? true : !hideSubsequentLogs;
});
}
setFilteredLogs(filteredLogsResult);
}, [data, showExtraLogs]);
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
return ( return (
<Dialog <Dialog
@@ -103,28 +118,49 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
<DialogContent className={"sm:max-w-5xl overflow-y-auto 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 className="flex items-center gap-2">
See all the details of this deployment | <Badge variant="blank" className="text-xs">{filteredLogs.length} lines</Badge> <span>
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
{serverId && (
<div className="flex items-center space-x-2">
<Checkbox
id="show-extra-logs"
checked={showExtraLogs}
onCheckedChange={(checked) =>
setShowExtraLogs(checked as boolean)
}
/>
<label
htmlFor="show-extra-logs"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Show Extra Logs
</label>
</div>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#d4d4d4] dark:bg-[#050506] rounded custom-logs-scrollbar" className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
> { >
filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => ( {" "}
<TerminalLine {filteredLogs.length > 0 ? (
key={index} filteredLogs.map((log: LogLine, index: number) => (
log={log} <TerminalLine key={index} log={log} noTimestamp />
noTimestamp ))
/> ) : (
)) : <div className="flex justify-center items-center h-full text-muted-foreground">
( <Loader2 className="h-6 w-6 animate-spin" />
<div className="flex justify-center items-center h-full text-muted-foreground"> </div>
<Loader2 className="h-6 w-6 animate-spin" /> )}
</div>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -264,21 +264,21 @@ export const AddDomain = ({
name="certificateType" name="certificateType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-2"> <FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel> <FormLabel>Certificate Provider</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value || ""} defaultValue={field.value || ""}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a certificate" /> <SelectValue placeholder="Select a certificate provider" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Letsencrypt (Default) Let's Encrypt
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -15,6 +15,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
@@ -35,34 +36,72 @@ interface Props {
} }
export const ShowDockerLogs = ({ appName, serverId }: Props) => { export const ShowDockerLogs = ({ appName, serverId }: Props) => {
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName,
},
);
const [containerId, setContainerId] = useState<string | undefined>(); const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native");
const { data: services, isLoading: servicesLoading } =
api.docker.getServiceContainersByAppName.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName && option === "swarm",
},
);
const { data: containers, isLoading: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName && option === "native",
},
);
useEffect(() => { useEffect(() => {
if (data && data?.length > 0) { if (option === "native") {
setContainerId(data[0]?.containerId); if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
} }
}, [data]); }, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
option === "native" ? containers?.length : services?.length;
return ( return (
<Card className="bg-background"> <Card className="bg-background">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Logs</CardTitle> <CardTitle className="text-xl">Logssss</CardTitle>
<CardDescription> <CardDescription>
Watch the logs of the application in real time Watch the logs of the application in real time
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
<Label>Select a container to view logs</Label> <div className="flex flex-row justify-between items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
{option === "native" ? "Native" : "Swarm"}
</span>
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setOption(checked ? "native" : "swarm");
}}
/>
</div>
</div>
<Select onValueChange={setContainerId} value={containerId}> <Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger> <SelectTrigger>
{isLoading ? ( {isLoading ? (
@@ -76,21 +115,40 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
{data?.map((container) => ( {option === "native" ? (
<SelectItem <div>
key={container.containerId} {containers?.map((container) => (
value={container.containerId} <SelectItem
> key={container.containerId}
{container.name} ({container.containerId}) {container.state} value={container.containerId}
</SelectItem> >
))} {container.name} ({container.containerId}){" "}
<SelectLabel>Containers ({data?.length})</SelectLabel> {container.state}
</SelectItem>
))}
</div>
) : (
<>
{services?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}@{container.node}
) {container.state}
</SelectItem>
))}
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
<DockerLogs <DockerLogs
serverId={serverId || ""} serverId={serverId || ""}
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
runType={option}
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -265,21 +265,21 @@ export const AddPreviewDomain = ({
name="certificateType" name="certificateType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-2"> <FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel> <FormLabel>Certificate Provider</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value || ""} defaultValue={field.value || ""}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a certificate" /> <SelectValue placeholder="Select a certificate provider" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Letsencrypt (Default) Let's Encrypt
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -18,15 +18,26 @@ import { ShowDeployment } from "../deployments/show-deployment";
interface Props { interface Props {
deployments: RouterOutputs["deployment"]["all"]; deployments: RouterOutputs["deployment"]["all"];
serverId?: string; serverId?: string;
trigger?: React.ReactNode;
} }
export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => { export const ShowPreviewBuilds = ({
deployments,
serverId,
trigger,
}: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null); const [activeLog, setActiveLog] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline">View Builds</Button> {trigger ? (
trigger
) : (
<Button className="sm:w-auto w-full" size="sm" variant="outline">
View Builds
</Button>
)}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
<DialogHeader> <DialogHeader>

View File

@@ -1,5 +1,8 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip"; import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -8,30 +11,34 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Pencil, RocketIcon } from "lucide-react"; import {
import React, { useEffect, useState } from "react"; ExternalLink,
FileText,
GitPullRequest,
Layers,
PenSquare,
RocketIcon,
Trash2,
} from "lucide-react";
import React from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ShowDeployment } from "../deployments/show-deployment";
import Link from "next/link";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs"; import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { DialogAction } from "@/components/shared/dialog-action";
import { AddPreviewDomain } from "./add-preview-domain"; import { AddPreviewDomain } from "./add-preview-domain";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { ShowPreviewSettings } from "./show-preview-settings";
import { ShowPreviewBuilds } from "./show-preview-builds"; import { ShowPreviewBuilds } from "./show-preview-builds";
import { ShowPreviewSettings } from "./show-preview-settings";
interface Props { interface Props {
applicationId: string; applicationId: string;
} }
export const ShowPreviewDeployments = ({ applicationId }: Props) => { export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data } = api.application.one.useQuery({ applicationId }); const { data } = api.application.one.useQuery({ applicationId });
const { mutateAsync: deletePreviewDeployment, isLoading } = const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation(); api.previewDeployment.delete.useMutation();
const { data: previewDeployments, refetch: refetchPreviewDeployments } = const { data: previewDeployments, refetch: refetchPreviewDeployments } =
api.previewDeployment.all.useQuery( api.previewDeployment.all.useQuery(
{ applicationId }, { applicationId },
@@ -39,10 +46,19 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
enabled: !!applicationId, enabled: !!applicationId,
}, },
); );
// const [url, setUrl] = React.useState("");
// useEffect(() => { const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
// setUrl(document.location.origin); deletePreviewDeployment({
// }, []); previewDeploymentId: previewDeploymentId,
})
.then(() => {
refetchPreviewDeployments();
toast.success("Preview deployment deleted");
})
.catch((error) => {
toast.error(error.message);
});
};
return ( return (
<Card className="bg-background"> <Card className="bg-background">
@@ -65,7 +81,7 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
each pull request you create. each pull request you create.
</span> </span>
</div> </div>
{data?.previewDeployments?.length === 0 ? ( {!previewDeployments?.length ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10"> <div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" /> <RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground"> <span className="text-base text-muted-foreground">
@@ -74,120 +90,131 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{previewDeployments?.map((previewDeployment) => { {previewDeployments?.map((deployment) => {
const { deployments, domain } = previewDeployment; const deploymentUrl = `${deployment.domain?.https ? "https" : "http"}://${deployment.domain?.host}${deployment.domain?.path || "/"}`;
const status = deployment.previewStatus;
return ( return (
<div <div
key={previewDeployment?.previewDeploymentId} key={deployment.previewDeploymentId}
className="flex flex-col justify-between rounded-lg border p-4 gap-2" className="group relative overflow-hidden border rounded-lg transition-colors"
> >
<div className="flex justify-between gap-2 max-sm:flex-wrap"> <div
<div className="flex flex-col gap-2"> className={`absolute left-0 top-0 w-1 h-full ${
{deployments?.length === 0 ? ( status === "done"
<div> ? "bg-green-500"
<span className="text-sm text-muted-foreground"> : status === "running"
No deployments found ? "bg-yellow-500"
</span> : "bg-red-500"
</div> }`}
) : ( />
<div className="flex items-center gap-2">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{previewDeployment?.pullRequestTitle}
</span>
<StatusTooltip
status={previewDeployment.previewStatus}
className="size-2.5"
/>
</div>
)}
<div className="flex flex-col gap-1">
{previewDeployment?.pullRequestTitle && (
<div className="flex items-center gap-2">
<span className="break-all text-sm text-muted-foreground w-fit">
Title: {previewDeployment?.pullRequestTitle}
</span>
</div>
)}
{previewDeployment?.pullRequestURL && ( <div className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-start justify-between mb-3">
<GithubIcon /> <div className="flex items-start gap-3">
<Link <GitPullRequest className="size-5 text-muted-foreground mt-1 flex-shrink-0" />
target="_blank" <div>
href={previewDeployment?.pullRequestURL} <div className="font-medium text-sm">
className="break-all text-sm text-muted-foreground w-fit hover:underline hover:text-foreground" {deployment.pullRequestTitle}
> </div>
Pull Request URL <div className="text-sm text-muted-foreground mt-1">
</Link> {deployment.branch}
</div> </div>
)}
</div>
<div className="flex flex-col ">
<span>Domain </span>
<div className="flex flex-row items-center gap-4">
<Link
target="_blank"
href={`http://${domain?.host}`}
className="text-sm text-muted-foreground w-fit hover:underline hover:text-foreground"
>
{domain?.host}
</Link>
<AddPreviewDomain
previewDeploymentId={
previewDeployment.previewDeploymentId
}
domainId={domain?.domainId}
>
<Button variant="outline" size="sm">
<Pencil className="size-4 text-muted-foreground" />
</Button>
</AddPreviewDomain>
</div> </div>
</div> </div>
<Badge variant="outline" className="gap-2">
<StatusTooltip
status={deployment.previewStatus}
className="size-2"
/>
<DateTooltip date={deployment.createdAt} />
</Badge>
</div> </div>
<div className="flex flex-col sm:items-end gap-2 max-sm:w-full"> <div className="pl-8 space-y-3">
{previewDeployment?.createdAt && ( <div className="relative flex-grow">
<div className="text-sm capitalize text-muted-foreground"> <Input
<DateTooltip value={deploymentUrl}
date={previewDeployment?.createdAt} readOnly
/> className="pr-8 text-sm text-blue-500 hover:text-blue-600 cursor-pointer"
</div> onClick={() =>
)} window.open(deploymentUrl, "_blank")
<ShowPreviewBuilds }
deployments={previewDeployment?.deployments || []} />
serverId={data?.serverId || ""} <ExternalLink className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-400" />
/> </div>
<ShowModalLogs <div className="flex gap-2 opacity-80 group-hover:opacity-100 transition-opacity">
appName={previewDeployment.appName} <Button
serverId={data?.serverId || ""} variant="outline"
> size="sm"
<Button variant="outline">View Logs</Button> className="gap-2"
</ShowModalLogs> onClick={() =>
window.open(deployment.pullRequestURL, "_blank")
<DialogAction }
title="Delete Preview" >
description="Are you sure you want to delete this preview?" <GithubIcon className="size-4" />
onClick={() => { Pull Request
deletePreviewDeployment({
previewDeploymentId:
previewDeployment.previewDeploymentId,
})
.then(() => {
refetchPreviewDeployments();
toast.success("Preview deployment deleted");
})
.catch((error) => {
toast.error(error.message);
});
}}
>
<Button variant="destructive" isLoading={isLoading}>
Delete Preview
</Button> </Button>
</DialogAction> <ShowModalLogs
appName={deployment.appName}
serverId={data?.serverId || ""}
>
<Button
variant="outline"
size="sm"
className="gap-2"
>
<FileText className="size-4" />
Logs
</Button>
</ShowModalLogs>
<ShowPreviewBuilds
deployments={deployment.deployments || []}
serverId={data?.serverId || ""}
trigger={
<Button
variant="outline"
size="sm"
className="gap-2"
>
<Layers className="size-4" />
Builds
</Button>
}
/>
<AddPreviewDomain
previewDeploymentId={`${deployment.previewDeploymentId}`}
domainId={deployment.domain?.domainId}
>
<Button
variant="ghost"
size="sm"
className="gap-2"
>
<PenSquare className="size-4" />
</Button>
</AddPreviewDomain>
<DialogAction
title="Delete Preview"
description="Are you sure you want to delete this preview?"
onClick={() =>
handleDeletePreviewDeployment(
deployment.previewDeploymentId,
)
}
>
<Button
variant="ghost"
size="sm"
isLoading={isLoading}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="size-4" />
</Button>
</DialogAction>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,3 @@
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -20,12 +18,7 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input"; import { Input, NumberInput } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Secrets } from "@/components/ui/secrets"; import { Secrets } from "@/components/ui/secrets";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -33,6 +26,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { Settings2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const schema = z.object({ const schema = z.object({
env: z.string(), env: z.string(),
@@ -116,7 +117,10 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<div> <div>
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline">View Settings</Button> <Button variant="outline">
<Settings2 className="size-4" />
Configure
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl w-full"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl w-full">
<DialogHeader> <DialogHeader>
@@ -218,21 +222,21 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
name="previewCertificateType" name="previewCertificateType"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Certificate</FormLabel> <FormLabel>Certificate Provider</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value || ""} defaultValue={field.value || ""}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a certificate" /> <SelectValue placeholder="Select a certificate provider" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Letsencrypt (Default) Let's Encrypt
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -91,7 +92,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
<div> <div>
<CardTitle className="text-xl">Run Command</CardTitle> <CardTitle className="text-xl">Run Command</CardTitle>
<CardDescription> <CardDescription>
Append a custom command to the compose file Override a custom command to the compose file
</CardDescription> </CardDescription>
</div> </div>
</CardHeader> </CardHeader>
@@ -101,6 +102,12 @@ export const AddCommandCompose = ({ composeId }: Props) => {
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4" className="grid w-full gap-4"
> >
<AlertBlock type="warning">
Modifying the default command may affect deployment stability,
impacting logs and monitoring. Proceed carefully and test
thoroughly. By default, the command starts with{" "}
<strong>docker</strong>.
</AlertBlock>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<FormField <FormField
control={form.control} control={form.control}

View File

@@ -1,5 +1,6 @@
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -12,6 +13,7 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -32,6 +34,7 @@ const deleteComposeSchema = z.object({
projectName: z.string().min(1, { projectName: z.string().min(1, {
message: "Compose name is required", message: "Compose name is required",
}), }),
deleteVolumes: z.boolean(),
}); });
type DeleteCompose = z.infer<typeof deleteComposeSchema>; type DeleteCompose = z.infer<typeof deleteComposeSchema>;
@@ -51,6 +54,7 @@ export const DeleteCompose = ({ composeId }: Props) => {
const form = useForm<DeleteCompose>({ const form = useForm<DeleteCompose>({
defaultValues: { defaultValues: {
projectName: "", projectName: "",
deleteVolumes: false,
}, },
resolver: zodResolver(deleteComposeSchema), resolver: zodResolver(deleteComposeSchema),
}); });
@@ -58,7 +62,8 @@ export const DeleteCompose = ({ composeId }: Props) => {
const onSubmit = async (formData: DeleteCompose) => { const onSubmit = async (formData: DeleteCompose) => {
const expectedName = `${data?.name}/${data?.appName}`; const expectedName = `${data?.name}/${data?.appName}`;
if (formData.projectName === expectedName) { if (formData.projectName === expectedName) {
await mutateAsync({ composeId }) const { deleteVolumes } = formData;
await mutateAsync({ composeId, deleteVolumes })
.then((result) => { .then((result) => {
push(`/dashboard/project/${result?.projectId}`); push(`/dashboard/project/${result?.projectId}`);
toast.success("Compose deleted successfully"); toast.success("Compose deleted successfully");
@@ -133,6 +138,27 @@ export const DeleteCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="deleteVolumes"
render={({ field }) => (
<FormItem>
<div className="flex items-center">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="ml-2">
Delete volumes associated with this compose
</FormLabel>
</div>
<FormMessage />
</FormItem>
)}
/>
</form> </form>
</Form> </Form>
</div> </div>

View File

@@ -1,3 +1,5 @@
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -5,12 +7,10 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { TerminalLine } from "../../docker/logs/terminal-line"; import { TerminalLine } from "../../docker/logs/terminal-line";
import { LogLine, parseLogs } from "../../docker/logs/utils"; import { type 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;
@@ -26,25 +26,25 @@ export const ShowDeploymentCompose = ({
}: Props) => { }: Props) => {
const [data, setData] = useState(""); const [data, setData] = useState("");
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [showExtraLogs, setShowExtraLogs] = useState(false);
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 [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => { const scrollToBottom = () => {
if (autoScroll && scrollRef.current) { if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}; };
const handleScroll = () => { const handleScroll = () => {
if (!scrollRef.current) return; if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom); setAutoScroll(isAtBottom);
}; };
useEffect(() => { useEffect(() => {
if (!open || !logPath) return; if (!open || !logPath) return;
@@ -76,19 +76,34 @@ export const ShowDeploymentCompose = ({
}; };
}, [logPath, open]); }, [logPath, open]);
useEffect(() => { useEffect(() => {
const logs = parseLogs(data); const logs = parseLogs(data);
setFilteredLogs(logs); let filteredLogsResult = logs;
}, [data]); if (serverId) {
let hideSubsequentLogs = false;
filteredLogsResult = logs.filter((log) => {
if (
log.message.includes(
"===================================EXTRA LOGS============================================",
)
) {
hideSubsequentLogs = true;
return showExtraLogs;
}
return showExtraLogs ? true : !hideSubsequentLogs;
});
}
setFilteredLogs(filteredLogsResult);
}, [data, showExtraLogs]);
useEffect(() => { useEffect(() => {
scrollToBottom(); scrollToBottom();
if (autoScroll && scrollRef.current) { if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}, [filteredLogs, autoScroll]); }, [filteredLogs, autoScroll]);
return ( return (
<Dialog <Dialog
@@ -109,32 +124,47 @@ export const ShowDeploymentCompose = ({
<DialogContent className={"sm:max-w-5xl max-h-screen"}> <DialogContent className={"sm:max-w-5xl max-h-screen"}>
<DialogHeader> <DialogHeader>
<DialogTitle>Deployment</DialogTitle> <DialogTitle>Deployment</DialogTitle>
<DialogDescription> <DialogDescription className="flex items-center gap-2">
See all the details of this deployment | <Badge variant="blank" className="text-xs">{filteredLogs.length} lines</Badge> <span>
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
{serverId && (
<div className="flex items-center space-x-2">
<Checkbox
id="show-extra-logs"
checked={showExtraLogs}
onCheckedChange={(checked) =>
setShowExtraLogs(checked as boolean)
}
/>
<label
htmlFor="show-extra-logs"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Show Extra Logs
</label>
</div>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div <div
ref={scrollRef} ref={scrollRef}
onScroll={handleScroll} onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#d4d4d4] dark:bg-[#050506] rounded custom-logs-scrollbar" className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
> >
{filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => (
{ <TerminalLine key={index} log={log} noTimestamp />
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"> <div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" /> <Loader2 className="h-6 w-6 animate-spin" />
</div> </div>
) )}
}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -400,21 +400,21 @@ export const AddDomainCompose = ({
name="certificateType" name="certificateType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="col-span-2"> <FormItem className="col-span-2">
<FormLabel>Certificate</FormLabel> <FormLabel>Certificate Provider</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value || ""} defaultValue={field.value || ""}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a certificate" /> <SelectValue placeholder="Select a certificate provider" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}> <SelectItem value={"letsencrypt"}>
Letsencrypt (Default) Let's Encrypt
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -53,7 +53,7 @@ export const DeployCompose = ({ composeId }: Props) => {
}) })
.then(async () => { .then(async () => {
router.push( router.push(
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments` `/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
); );
}) })
.catch(() => { .catch(() => {

View File

@@ -0,0 +1,156 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
export const DockerLogs = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
serverId?: string;
}
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();
const { data: services, isLoading: servicesLoading } =
api.docker.getStackContainersByAppName.useQuery(
{
appName,
serverId,
},
{
enabled: !!appName && option === "swarm",
},
);
const { data: containers, isLoading: containersLoading } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType: "stack",
serverId,
},
{
enabled: !!appName && option === "native",
},
);
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
option === "native" ? containers?.length : services?.length;
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Logs</CardTitle>
<CardDescription>
Watch the logs of the application in real time
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
{option === "native" ? "Native" : "Swarm"}
</span>
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setOption(checked ? "native" : "swarm");
}}
/>
</div>
</div>
<Select onValueChange={setContainerId} value={containerId}>
<SelectTrigger>
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<SelectValue placeholder="Select a container" />
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{option === "native" ? (
<div>
{containers?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}){" "}
{container.state}
</SelectItem>
))}
</div>
) : (
<>
{services?.map((container) => (
<SelectItem
key={container.containerId}
value={container.containerId}
>
{container.name} ({container.containerId}@{container.node}
) {container.state}
</SelectItem>
))}
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<DockerLogs
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
/>
</CardContent>
</Card>
);
};

View File

@@ -97,6 +97,7 @@ export const ShowDockerLogsCompose = ({
<DockerLogs <DockerLogs
serverId={serverId || ""} serverId={serverId || ""}
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
runType="native"
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,309 +1,297 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Download as DownloadIcon, Loader2 } from "lucide-react"; import { Download as DownloadIcon, Loader2 } from "lucide-react";
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { LineCountFilter } from "./line-count-filter";
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
import { StatusLogsFilter } from "./status-logs-filter";
import { TerminalLine } from "./terminal-line"; import { TerminalLine } from "./terminal-line";
import { type LogLine, getLogType, parseLogs } from "./utils"; import { type LogLine, getLogType, parseLogs } from "./utils";
interface Props { interface Props {
containerId: string; containerId: string;
serverId?: string | null; serverId?: string | null;
runType: "swarm" | "native";
} }
type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h"; export const priorities = [
type TypeFilter = "all" | "error" | "warning" | "success" | "info" | "debug"; {
label: "Info",
value: "info",
},
{
label: "Success",
value: "success",
},
{
label: "Warning",
value: "warning",
},
{
label: "Debug",
value: "debug",
},
{
label: "Error",
value: "error",
},
];
export const DockerLogsId: React.FC<Props> = ({ containerId, serverId }) => { export const DockerLogsId: React.FC<Props> = ({
const { data } = api.docker.getConfig.useQuery( containerId,
{ serverId,
containerId, runType,
serverId: serverId ?? undefined, }) => {
}, const { data } = api.docker.getConfig.useQuery(
{ {
enabled: !!containerId, containerId,
} serverId: serverId ?? undefined,
); },
{
enabled: !!containerId,
},
);
const [rawLogs, setRawLogs] = React.useState(""); const [rawLogs, setRawLogs] = React.useState("");
const [filteredLogs, setFilteredLogs] = React.useState<LogLine[]>([]); const [filteredLogs, setFilteredLogs] = React.useState<LogLine[]>([]);
const [autoScroll, setAutoScroll] = React.useState(true); const [autoScroll, setAutoScroll] = React.useState(true);
const [lines, setLines] = React.useState<number>(100); const [lines, setLines] = React.useState<number>(100);
const [search, setSearch] = React.useState<string>(""); 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 [since, setSince] = React.useState<TimeFilter>("all"); const scrollToBottom = () => {
const [typeFilter, setTypeFilter] = React.useState<TypeFilter>("all"); if (autoScroll && scrollRef.current) {
const scrollRef = useRef<HTMLDivElement>(null); scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
const [isLoading, setIsLoading] = React.useState(false); }
};
const scrollToBottom = () => { const handleScroll = () => {
if (autoScroll && scrollRef.current) { if (!scrollRef.current) return;
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
if (!scrollRef.current) return; const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
};
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10; setSearch(e.target.value || "");
setAutoScroll(isAtBottom); };
};
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLines = (lines: number) => {
setSearch(e.target.value || ""); setRawLogs("");
}; setFilteredLogs([]);
setLines(lines);
};
const handleLines = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSince = (value: TimeFilter) => {
setRawLogs(""); setRawLogs("");
setFilteredLogs([]); setFilteredLogs([]);
setLines(Number(e.target.value) || 1); setSince(value);
}; };
const handleSince = (value: TimeFilter) => { useEffect(() => {
setRawLogs(""); if (!containerId) return;
setFilteredLogs([]);
setSince(value);
};
const handleTypeFilter = (value: TypeFilter) => { let isCurrentConnection = true;
setTypeFilter(value); let noDataTimeout: NodeJS.Timeout;
}; setIsLoading(true);
setRawLogs("");
setFilteredLogs([]);
useEffect(() => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
if (!containerId) return; const params = new globalThis.URLSearchParams({
containerId,
let isCurrentConnection = true; tail: lines.toString(),
let noDataTimeout: NodeJS.Timeout; since,
setIsLoading(true); search,
setRawLogs(""); runType,
setFilteredLogs([]); });
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; if (serverId) {
const params = new globalThis.URLSearchParams({ params.append("serverId", serverId);
containerId, }
tail: lines.toString(),
since,
search,
});
if (serverId) { const wsUrl = `${protocol}//${
params.append("serverId", serverId); window.location.host
} }/docker-container-logs?${params.toString()}`;
console.log("Connecting to WebSocket:", wsUrl);
const ws = new WebSocket(wsUrl);
const wsUrl = `${protocol}//${ const resetNoDataTimeout = () => {
window.location.host if (noDataTimeout) clearTimeout(noDataTimeout);
}/docker-container-logs?${params.toString()}`; noDataTimeout = setTimeout(() => {
console.log("Connecting to WebSocket:", wsUrl); if (isCurrentConnection) {
const ws = new WebSocket(wsUrl); setIsLoading(false);
}
}, 2000); // Wait 2 seconds for data before showing "No logs found"
};
const resetNoDataTimeout = () => { ws.onopen = () => {
if (noDataTimeout) clearTimeout(noDataTimeout); if (!isCurrentConnection) {
noDataTimeout = setTimeout(() => { ws.close();
if (isCurrentConnection) { return;
setIsLoading(false); }
} console.log("WebSocket connected");
}, 2000); // Wait 2 seconds for data before showing "No logs found" resetNoDataTimeout();
}; };
ws.onopen = () => { ws.onmessage = (e) => {
if (!isCurrentConnection) { if (!isCurrentConnection) return;
ws.close(); setRawLogs((prev) => prev + e.data);
return; setIsLoading(false);
} if (noDataTimeout) clearTimeout(noDataTimeout);
console.log("WebSocket connected"); };
resetNoDataTimeout();
};
ws.onmessage = (e) => { ws.onerror = (error) => {
if (!isCurrentConnection) return; if (!isCurrentConnection) return;
setRawLogs((prev) => prev + e.data); console.error("WebSocket error:", error);
setIsLoading(false); setIsLoading(false);
if (noDataTimeout) clearTimeout(noDataTimeout); if (noDataTimeout) clearTimeout(noDataTimeout);
}; };
ws.onerror = (error) => { ws.onclose = (e) => {
if (!isCurrentConnection) return; if (!isCurrentConnection) return;
console.error("WebSocket error:", error); console.log("WebSocket closed:", e.reason);
setIsLoading(false); setIsLoading(false);
if (noDataTimeout) clearTimeout(noDataTimeout); if (noDataTimeout) clearTimeout(noDataTimeout);
}; };
ws.onclose = (e) => { return () => {
if (!isCurrentConnection) return; isCurrentConnection = false;
console.log("WebSocket closed:", e.reason); if (noDataTimeout) clearTimeout(noDataTimeout);
setIsLoading(false); if (ws.readyState === WebSocket.OPEN) {
if (noDataTimeout) clearTimeout(noDataTimeout); ws.close();
}; }
};
}, [containerId, serverId, lines, search, since]);
return () => { const handleDownload = () => {
isCurrentConnection = false; const logContent = filteredLogs
if (noDataTimeout) clearTimeout(noDataTimeout); .map(
if (ws.readyState === WebSocket.OPEN) { ({ timestamp, message }: { timestamp: Date | null; message: string }) =>
ws.close(); `${timestamp?.toISOString() || "No timestamp"} ${message}`,
} )
}; .join("\n");
}, [containerId, serverId, lines, search, since]);
const handleDownload = () => { const blob = new Blob([logContent], { type: "text/plain" });
const logContent = filteredLogs const url = URL.createObjectURL(blob);
.map( const a = document.createElement("a");
({ timestamp, message }: { timestamp: Date | null; message: string }) => const appName = data.Name.replace("/", "") || "app";
`${timestamp?.toISOString() || "No timestamp"} ${message}` const isoDate = new Date().toISOString();
) a.href = url;
.join("\n"); 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 blob = new Blob([logContent], { type: "text/plain" }); const handleFilter = (logs: LogLine[]) => {
const url = URL.createObjectURL(blob); return logs.filter((log) => {
const a = document.createElement("a"); const logType = getLogType(log.message).type;
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[]) => { if (typeFilter.length === 0) {
return logs.filter((log) => { return true;
const logType = getLogType(log.message).type; }
const matchesType = typeFilter === "all" || logType === typeFilter; return typeFilter.includes(logType);
});
};
return matchesType; useEffect(() => {
}); setRawLogs("");
}; setFilteredLogs([]);
}, [containerId]);
useEffect(() => { useEffect(() => {
setRawLogs(""); const logs = parseLogs(rawLogs);
setFilteredLogs([]); const filtered = handleFilter(logs);
}, [containerId]); setFilteredLogs(filtered);
}, [rawLogs, search, lines, since, typeFilter]);
useEffect(() => { useEffect(() => {
const logs = parseLogs(rawLogs); scrollToBottom();
const filtered = handleFilter(logs);
setFilteredLogs(filtered);
}, [rawLogs, search, lines, since, typeFilter]);
useEffect(() => { if (autoScroll && scrollRef.current) {
scrollToBottom(); scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
if (autoScroll && scrollRef.current) { return (
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; <div className="flex flex-col gap-4">
} <div className="rounded-lg overflow-hidden">
}, [filteredLogs, autoScroll]); <div className="space-y-4">
<div className="flex flex-wrap justify-between items-start sm:items-center gap-4">
<div className="flex flex-wrap gap-4">
<LineCountFilter value={lines} onValueChange={handleLines} />
return ( <SinceLogsFilter
<div className="flex flex-col gap-4"> value={since}
<div className="rounded-lg overflow-hidden"> onValueChange={handleSince}
<div className="space-y-4"> showTimestamp={showTimestamp}
<div className="flex flex-wrap justify-between items-start sm:items-center gap-4"> onTimestampChange={setShowTimestamp}
<div className="flex flex-wrap gap-4"> />
<Input
type="text"
placeholder="Number of lines to show"
value={lines}
onChange={handleLines}
className="inline-flex h-9 text-sm placeholder-gray-400 w-full sm:w-auto"
/>
<Select value={since} onValueChange={handleSince}> <StatusLogsFilter
<SelectTrigger className="sm:w-[180px] w-full h-9"> value={typeFilter}
<SelectValue placeholder="Time filter" /> setValue={setTypeFilter}
</SelectTrigger> title="Log type"
<SelectContent> options={priorities}
<SelectItem value="1h">Last hour</SelectItem> />
<SelectItem value="6h">Last 6 hours</SelectItem>
<SelectItem value="24h">Last 24 hours</SelectItem>
<SelectItem value="168h">Last 7 days</SelectItem>
<SelectItem value="720h">Last 30 days</SelectItem>
<SelectItem value="all">All time</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={handleTypeFilter}> <Input
<SelectTrigger className="sm:w-[180px] w-full h-9"> type="search"
<SelectValue placeholder="Type filter" /> placeholder="Search logs..."
</SelectTrigger> value={search}
<SelectContent> onChange={handleSearch}
<SelectItem value="all"> className="inline-flex h-9 text-sm placeholder-gray-400 w-full sm:w-auto"
<Badge variant="blank">All</Badge> />
</SelectItem> </div>
<SelectItem value="error">
<Badge variant="red">Error</Badge>
</SelectItem>
<SelectItem value="warning">
<Badge variant="orange">Warning</Badge>
</SelectItem>
<SelectItem value="debug">
<Badge variant="yellow">Debug</Badge>
</SelectItem>
<SelectItem value="success">
<Badge variant="green">Success</Badge>
</SelectItem>
<SelectItem value="info">
<Badge variant="blue">Info</Badge>
</SelectItem>
</SelectContent>
</Select>
<Input <Button
type="search" variant="outline"
placeholder="Search logs..." size="sm"
value={search} className="h-9 sm:w-auto w-full"
onChange={handleSearch} onClick={handleDownload}
className="inline-flex h-9 text-sm placeholder-gray-400 w-full sm:w-auto" disabled={filteredLogs.length === 0 || !data?.Name}
/> >
</div> <DownloadIcon className="mr-2 h-4 w-4" />
Download logs
<Button </Button>
variant="outline" </div>
size="sm" <div
className="h-9" ref={scrollRef}
onClick={handleDownload} onScroll={handleScroll}
disabled={filteredLogs.length === 0 || !data?.Name} className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
> >
<DownloadIcon className="mr-2 h-4 w-4" /> {filteredLogs.length > 0 ? (
Download logs filteredLogs.map((filteredLog: LogLine, index: number) => (
</Button> <TerminalLine
</div> key={index}
<div log={filteredLog}
ref={scrollRef} searchTerm={search}
onScroll={handleScroll} noTimestamp={!showTimestamp}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#d4d4d4] dark:bg-[#050506] rounded custom-logs-scrollbar" />
> ))
{filteredLogs.length > 0 ? ( ) : isLoading ? (
filteredLogs.map((filteredLog: LogLine, index: number) => ( <div className="flex justify-center items-center h-full text-muted-foreground">
<TerminalLine <Loader2 className="h-6 w-6 animate-spin" />
key={index} </div>
log={filteredLog} ) : (
searchTerm={search} <div className="flex justify-center items-center h-full text-muted-foreground">
/> No logs found
)) </div>
) : isLoading ? ( )}
<div className="flex justify-center items-center h-full text-muted-foreground"> </div>
<Loader2 className="h-6 w-6 animate-spin" /> </div>
</div> </div>
) : ( </div>
<div className="flex justify-center items-center h-full text-muted-foreground"> );
No logs found };
</div>
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,173 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { Command as CommandPrimitive } from "cmdk";
import { debounce } from "lodash";
import { CheckIcon, Hash } from "lucide-react";
import React, { useCallback, useRef } from "react";
const lineCountOptions = [
{ label: "100 lines", value: 100 },
{ label: "300 lines", value: 300 },
{ label: "500 lines", value: 500 },
{ label: "1000 lines", value: 1000 },
{ label: "5000 lines", value: 5000 },
] as const;
interface LineCountFilterProps {
value: number;
onValueChange: (value: number) => void;
title?: string;
}
export function LineCountFilter({
value,
onValueChange,
title = "Limit to",
}: LineCountFilterProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState("");
const pendingValueRef = useRef<number | null>(null);
const isPresetValue = lineCountOptions.some(
(option) => option.value === value,
);
const debouncedValueChange = useCallback(
debounce((numValue: number) => {
if (numValue > 0 && numValue !== value) {
onValueChange(numValue);
pendingValueRef.current = null;
}
}, 500),
[onValueChange, value],
);
const handleInputChange = (input: string) => {
setInputValue(input);
// Extract numbers from input and convert
const numValue = Number.parseInt(input.replace(/[^0-9]/g, ""));
if (!Number.isNaN(numValue)) {
pendingValueRef.current = numValue;
debouncedValueChange(numValue);
}
};
const handleSelect = (selectedValue: string) => {
const preset = lineCountOptions.find((opt) => opt.label === selectedValue);
if (preset) {
if (preset.value !== value) {
onValueChange(preset.value);
}
setInputValue("");
setOpen(false);
return;
}
const numValue = Number.parseInt(selectedValue);
if (
!Number.isNaN(numValue) &&
numValue > 0 &&
numValue !== value &&
numValue !== pendingValueRef.current
) {
onValueChange(numValue);
setInputValue("");
setOpen(false);
}
};
React.useEffect(() => {
return () => {
debouncedValueChange.cancel();
};
}, [debouncedValueChange]);
const displayValue = isPresetValue
? lineCountOptions.find((option) => option.value === value)?.label
: `${value} lines`;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
>
{title}
<Separator orientation="vertical" className="mx-2 h-4" />
<div className="space-x-1 flex">
<Badge variant="blank" className="rounded-sm px-1 font-normal">
{displayValue}
</Badge>
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<CommandPrimitive className="overflow-hidden rounded-md border border-none bg-popover text-popover-foreground">
<div className="flex items-center border-b px-3">
<Hash className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
placeholder="Number of lines"
value={inputValue}
onValueChange={handleInputChange}
className="flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const numValue = Number.parseInt(
inputValue.replace(/[^0-9]/g, ""),
);
if (
!Number.isNaN(numValue) &&
numValue > 0 &&
numValue !== value &&
numValue !== pendingValueRef.current
) {
handleSelect(inputValue);
}
}
}}
/>
</div>
<CommandPrimitive.List className="max-h-[300px] overflow-y-auto overflow-x-hidden">
<CommandPrimitive.Group className="px-2 py-1.5">
{lineCountOptions.map((option) => {
const isSelected = value === option.value;
return (
<CommandPrimitive.Item
key={option.value}
onSelect={() => handleSelect(option.label)}
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded-sm border border-primary mr-2",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className={cn("h-4 w-4")} />
</div>
<span>{option.label}</span>
</CommandPrimitive.Item>
);
})}
</CommandPrimitive.Group>
</CommandPrimitive.List>
</CommandPrimitive>
</PopoverContent>
</Popover>
);
}
export default LineCountFilter;

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
containerId={containerId || ""}
serverId={serverId}
runType="native"
/>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -0,0 +1,125 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { CheckIcon } from "lucide-react";
import React from "react";
export type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h";
const timeRanges: Array<{ label: string; value: TimeFilter }> = [
{
label: "All time",
value: "all",
},
{
label: "Last hour",
value: "1h",
},
{
label: "Last 6 hours",
value: "6h",
},
{
label: "Last 24 hours",
value: "24h",
},
{
label: "Last 7 days",
value: "168h",
},
{
label: "Last 30 days",
value: "720h",
},
] as const;
interface SinceLogsFilterProps {
value: TimeFilter;
onValueChange: (value: TimeFilter) => void;
showTimestamp: boolean;
onTimestampChange: (show: boolean) => void;
title?: string;
}
export function SinceLogsFilter({
value,
onValueChange,
showTimestamp,
onTimestampChange,
title = "Time range",
}: SinceLogsFilterProps) {
const selectedLabel =
timeRanges.find((range) => range.value === value)?.label ??
"Select time range";
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
>
{title}
<Separator orientation="vertical" className="mx-2 h-4" />
<div className="space-x-1 flex">
<Badge variant="blank" className="rounded-sm px-1 font-normal">
{selectedLabel}
</Badge>
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
{timeRanges.map((range) => {
const isSelected = value === range.value;
return (
<CommandItem
key={range.value}
onSelect={() => {
if (!isSelected) {
onValueChange(range.value);
}
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className={cn("h-4 w-4")} />
</div>
<span className="text-sm">{range.label}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
<Separator className="my-2" />
<div className="p-2 flex items-center justify-between">
<span className="text-sm">Show timestamps</span>
<Switch checked={showTimestamp} onCheckedChange={onTimestampChange} />
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,170 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { CheckIcon } from "lucide-react";
import type React from "react";
interface StatusLogsFilterProps {
value?: string[];
setValue?: (value: string[]) => void;
title?: string;
options: {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }>;
}[];
}
export function StatusLogsFilter({
value = [],
setValue,
title,
options,
}: StatusLogsFilterProps) {
const selectedValues = new Set(value as string[]);
const allSelected = selectedValues.size === 0;
const getSelectedBadges = () => {
if (allSelected) {
return (
<Badge variant="blank" className="rounded-sm px-1 font-normal">
All
</Badge>
);
}
if (selectedValues.size >= 1) {
const selected = options.find((opt) => selectedValues.has(opt.value));
return (
<>
<Badge
variant={
selected?.value === "success"
? "green"
: selected?.value === "error"
? "red"
: selected?.value === "warning"
? "orange"
: selected?.value === "info"
? "blue"
: selected?.value === "debug"
? "yellow"
: "blank"
}
className="rounded-sm px-1 font-normal"
>
{selected?.label}
</Badge>
{selectedValues.size > 1 && (
<Badge variant="blank" className="rounded-sm px-1 font-normal">
+{selectedValues.size - 1}
</Badge>
)}
</>
);
}
return null;
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
>
{title}
<Separator orientation="vertical" className="mx-2 h-4" />
<div className="space-x-1 flex">{getSelectedBadges()}</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandList>
<CommandGroup>
<CommandItem
onSelect={() => {
setValue?.([]); // Empty array means "All"
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
allSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className={cn("h-4 w-4")} />
</div>
<Badge variant="blank">All</Badge>
</CommandItem>
{options.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
const newValues = new Set(selectedValues);
if (isSelected) {
newValues.delete(option.value);
} else {
newValues.add(option.value);
}
setValue?.(Array.from(newValues));
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className={cn("h-4 w-4")} />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<Badge
variant={
option.value === "success"
? "green"
: option.value === "error"
? "red"
: option.value === "warning"
? "orange"
: option.value === "info"
? "blue"
: option.value === "debug"
? "yellow"
: "blank"
}
>
{option.label}
</Badge>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -7,6 +7,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { FancyAnsi } from "fancy-ansi";
import { escapeRegExp } from "lodash"; import { escapeRegExp } from "lodash";
import React from "react"; import React from "react";
import { type LogLine, getLogType } from "./utils"; import { type LogLine, getLogType } from "./utils";
@@ -17,6 +18,8 @@ interface LogLineProps {
searchTerm?: string; searchTerm?: string;
} }
const fancyAnsi = new FancyAnsi();
export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) { export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
const { timestamp, message, rawTimestamp } = log; const { timestamp, message, rawTimestamp } = log;
const { type, variant, color } = getLogType(message); const { type, variant, color } = getLogType(message);
@@ -33,17 +36,42 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
: "--- No time found ---"; : "--- No time found ---";
const highlightMessage = (text: string, term: string) => { const highlightMessage = (text: string, term: string) => {
if (!term) return text; if (!term) {
return (
<span
className="transition-colors"
dangerouslySetInnerHTML={{
__html: fancyAnsi.toHtml(text),
}}
/>
);
}
const parts = text.split(new RegExp(`(${escapeRegExp(term)})`, "gi")); const htmlContent = fancyAnsi.toHtml(text);
return parts.map((part, index) => const modifiedContent = htmlContent.replace(
part.toLowerCase() === term.toLowerCase() ? ( /<span([^>]*)>([^<]*)<\/span>/g,
<span key={index} className="bg-yellow-200 dark:bg-yellow-900"> (match, attrs, content) => {
{part} const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
</span> if (!content.match(searchRegex)) return match;
) : (
part const segments = content.split(searchRegex);
), const wrappedSegments = segments
.map((segment: string) =>
segment.toLowerCase() === term.toLowerCase()
? `<span${attrs} class="bg-yellow-200/50 dark:bg-yellow-900/50">${segment}</span>`
: segment,
)
.join("");
return `<span${attrs}>${wrappedSegments}</span>`;
},
);
return (
<span
className="transition-colors"
dangerouslySetInnerHTML={{ __html: modifiedContent }}
/>
); );
}; };
@@ -104,7 +132,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
</Badge> </Badge>
</div> </div>
<span className="dark:text-gray-200 font-mono text-foreground whitespace-pre-wrap break-all"> <span className="dark:text-gray-200 font-mono text-foreground whitespace-pre-wrap break-all">
{searchTerm ? highlightMessage(message, searchTerm) : message} {highlightMessage(message, searchTerm || "")}
</span> </span>
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
export type LogType = "error" | "warning" | "success" | "info" | "debug"; export type LogType = "error" | "warning" | "success" | "info" | "debug";
export type LogVariant = "red" | "yellow" | "green" | "blue" | "orange"; export type LogVariant = "red" | "yellow" | "green" | "blue" | "orange";
export interface LogLine { export interface LogLine {
rawTimestamp: string | null; rawTimestamp: string | null;
@@ -138,8 +138,12 @@ export const getLogType = (message: string): LogStyle => {
if ( if (
/(?:^|\s)(?:info|inf):?\s/i.test(lowerMessage) || /(?:^|\s)(?:info|inf):?\s/i.test(lowerMessage) ||
/\[(info|log|debug|trace|server|db|api|http|request|response)\]/i.test(lowerMessage) || /\[(info|log|debug|trace|server|db|api|http|request|response)\]/i.test(
/\b(?:version|config|import|load|get|HTTP|PATCH|POST|debug)\b:?/i.test(lowerMessage) lowerMessage,
) ||
/\b(?:version|config|import|load|get|HTTP|PATCH|POST|debug)\b:?/i.test(
lowerMessage,
)
) { ) {
return LOG_STYLES.debug; return LOG_STYLES.debug;
} }

View File

@@ -59,7 +59,10 @@ export const DockerTerminalModal = ({
{children} {children}
</DropdownMenuItem> </DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl"> <DialogContent
className="max-h-screen overflow-y-auto sm:max-w-7xl"
onEscapeKeyDown={(event) => event.preventDefault()}
>
<DialogHeader> <DialogHeader>
<DialogTitle>Docker Terminal</DialogTitle> <DialogTitle>Docker Terminal</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -73,7 +76,7 @@ export const DockerTerminalModal = ({
serverId={serverId || ""} serverId={serverId || ""}
/> />
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}> <Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent> <DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Are you sure you want to close the terminal? Are you sure you want to close the terminal?

View File

@@ -4,6 +4,7 @@ import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AttachAddon } from "@xterm/addon-attach"; import { AttachAddon } from "@xterm/addon-attach";
import { useTheme } from "next-themes";
interface Props { interface Props {
id: string; id: string;
@@ -18,6 +19,7 @@ export const DockerTerminal: React.FC<Props> = ({
}) => { }) => {
const termRef = useRef(null); const termRef = useRef(null);
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash"); const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
const { resolvedTheme } = useTheme();
useEffect(() => { useEffect(() => {
const container = document.getElementById(id); const container = document.getElementById(id);
if (container) { if (container) {
@@ -28,8 +30,9 @@ export const DockerTerminal: React.FC<Props> = ({
lineHeight: 1.4, lineHeight: 1.4,
convertEol: true, convertEol: true,
theme: { theme: {
cursor: "transparent", cursor: resolvedTheme === "light" ? "#000000" : "transparent",
background: "rgba(0, 0, 0, 0)", background: "rgba(0, 0, 0, 0)",
foreground: "currentColor",
}, },
}); });
const addonFit = new FitAddon(); const addonFit = new FitAddon();

View File

@@ -213,7 +213,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
name="appName" name="appName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>AppName</FormLabel> <FormLabel>App Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="my-app" {...field} /> <Input placeholder="my-app" {...field} />
</FormControl> </FormControl>

View File

@@ -220,7 +220,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
name="appName" name="appName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>AppName</FormLabel> <FormLabel>App Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="my-app" {...field} /> <Input placeholder="my-app" {...field} />
</FormControl> </FormControl>

View File

@@ -416,7 +416,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
name="appName" name="appName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>AppName</FormLabel> <FormLabel>App Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="my-app" {...field} /> <Input placeholder="my-app" {...field} />
</FormControl> </FormControl>
@@ -475,6 +475,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
<FormControl> <FormControl>
<Input <Input
placeholder={`Default ${databasesUserDefaultPlaceholder[type]}`} placeholder={`Default ${databasesUserDefaultPlaceholder[type]}`}
autoComplete="off"
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -495,6 +496,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
<Input <Input
type="password" type="password"
placeholder="******************" placeholder="******************"
autoComplete="off"
{...field} {...field}
/> />
</FormControl> </FormControl>

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,257 @@ import { ProjectEnviroment } from "./project-enviroment";
import { UpdateProject } from "./update"; import { UpdateProject } from "./update";
export const ShowProjects = () => { export const ShowProjects = () => {
const utils = api.useUtils(); const utils = api.useUtils();
const { data } = api.project.all.useQuery(); const { data } = api.project.all.useQuery();
const { data: auth } = api.auth.get.useQuery(); const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery( const { data: user } = api.user.byAuthId.useQuery(
{ {
authId: auth?.id || "", authId: auth?.id || "",
}, },
{ {
enabled: !!auth?.id && auth?.rol === "user", enabled: !!auth?.id && auth?.rol === "user",
} },
); );
const { mutateAsync } = api.project.remove.useMutation(); const { mutateAsync } = api.project.remove.useMutation();
return ( return (
<> <>
{data?.length === 0 && ( {data?.length === 0 && (
<div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4"> <div className="mt-6 flex h-[50vh] w-full flex-col items-center justify-center space-y-4">
<FolderInput className="size-10 md:size-28 text-muted-foreground" /> <FolderInput className="size-10 md:size-28 text-muted-foreground" />
<span className="text-center font-medium text-muted-foreground"> <span className="text-center font-medium text-muted-foreground">
No projects added yet. Click on Create project. No projects added yet. Click on Create project.
</span> </span>
</div> </div>
)} )}
<div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10"> <div className="mt-6 w-full grid sm:grid-cols-2 lg:grid-cols-3 flex-wrap gap-5 pb-10">
{data?.map((project) => { {data?.map((project) => {
const emptyServices = const emptyServices =
project?.mariadb.length === 0 && project?.mariadb.length === 0 &&
project?.mongo.length === 0 && project?.mongo.length === 0 &&
project?.mysql.length === 0 && project?.mysql.length === 0 &&
project?.postgres.length === 0 && project?.postgres.length === 0 &&
project?.redis.length === 0 && project?.redis.length === 0 &&
project?.applications.length === 0 && project?.applications.length === 0 &&
project?.compose.length === 0; project?.compose.length === 0;
const totalServices = const totalServices =
project?.mariadb.length + project?.mariadb.length +
project?.mongo.length + project?.mongo.length +
project?.mysql.length + project?.mysql.length +
project?.postgres.length + project?.postgres.length +
project?.redis.length + project?.redis.length +
project?.applications.length + project?.applications.length +
project?.compose.length; project?.compose.length;
const flattedDomains = [ const flattedDomains = [
...project.applications.flatMap((a) => a.domains), ...project.applications.flatMap((a) => a.domains),
...project.compose.flatMap((a) => a.domains), ...project.compose.flatMap((a) => a.domains),
]; ];
const renderDomainsDropdown = ( const renderDomainsDropdown = (
item: typeof project.compose | typeof project.applications item: typeof project.compose | typeof project.applications,
) => ) =>
item[0] ? ( item[0] ? (
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel> <DropdownMenuLabel>
{"applicationId" in item[0] ? "Applications" : "Compose"} {"applicationId" in item[0] ? "Applications" : "Compose"}
</DropdownMenuLabel> </DropdownMenuLabel>
{item.map((a) => ( {item.map((a) => (
<Fragment <Fragment
key={"applicationId" in a ? a.applicationId : a.composeId} key={"applicationId" in a ? a.applicationId : a.composeId}
> >
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs "> <DropdownMenuLabel className="font-normal capitalize text-xs ">
{a.name} {a.name}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{a.domains.map((domain) => ( {a.domains.map((domain) => (
<DropdownMenuItem key={domain.domainId} asChild> <DropdownMenuItem key={domain.domainId} asChild>
<Link <Link
className="space-x-4 text-xs cursor-pointer justify-between" className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank" target="_blank"
href={`${domain.https ? "https" : "http"}://${ href={`${domain.https ? "https" : "http"}://${
domain.host domain.host
}${domain.path}`} }${domain.path}`}
> >
<span>{domain.host}</span> <span>{domain.host}</span>
<ExternalLink className="size-4 shrink-0" /> <ExternalLink className="size-4 shrink-0" />
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
</Fragment> </Fragment>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
) : null; ) : 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].https ? "https" : "http"
}://${flattedDomains[0].host}${flattedDomains[0].path}`} }://${flattedDomains[0].host}${flattedDomains[0].path}`}
target="_blank" target="_blank"
> >
<ExternalLinkIcon className="size-3.5" /> <ExternalLinkIcon className="size-3.5" />
</Link> </Link>
</Button> </Button>
) : null} ) : null}
<CardHeader> <CardHeader>
<CardTitle className="flex items-center justify-between gap-2"> <CardTitle className="flex items-center justify-between gap-2">
<span className="flex flex-col gap-1.5"> <span className="flex flex-col gap-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BookIcon className="size-4 text-muted-foreground" /> <BookIcon className="size-4 text-muted-foreground" />
<span className="text-base font-medium leading-none"> <span className="text-base font-medium leading-none">
{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,189 +1,189 @@
"use client"; "use client";
import React from "react";
import { import {
Command, MariadbIcon,
CommandEmpty, MongodbIcon,
CommandList, MysqlIcon,
CommandGroup, PostgresqlIcon,
CommandInput, RedisIcon,
CommandItem, } from "@/components/icons/data-tools-icons";
CommandDialog, import { Badge } from "@/components/ui/badge";
CommandSeparator, import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { useRouter } from "next/router";
import { import {
extractServices, type Services,
type Services, extractServices,
} from "@/pages/dashboard/project/[projectId]"; } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import type { findProjectById } from "@dokploy/server/services/project"; import type { findProjectById } from "@dokploy/server/services/project";
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react"; import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
import { import { useRouter } from "next/router";
MariadbIcon, import React from "react";
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"; import { StatusTooltip } from "../shared/status-tooltip";
type Project = Awaited<ReturnType<typeof findProjectById>>; type Project = Awaited<ReturnType<typeof findProjectById>>;
export const SearchCommand = () => { export const SearchCommand = () => {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const { data } = api.project.all.useQuery(); const { data } = api.project.all.useQuery();
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery(); const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
React.useEffect(() => { React.useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
if (e.key === "j" && (e.metaKey || e.ctrlKey)) { if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
e.preventDefault(); e.preventDefault();
setOpen((open) => !open); setOpen((open) => !open);
} }
}; };
document.addEventListener("keydown", down); document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down); return () => document.removeEventListener("keydown", down);
}, []); }, []);
return ( return (
<div> <div>
<CommandDialog open={open} onOpenChange={setOpen}> <CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput <CommandInput
placeholder={"Search projects or settings"} placeholder={"Search projects or settings"}
value={search} value={search}
onValueChange={setSearch} onValueChange={setSearch}
/> />
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
No projects added yet. Click on Create project. No projects added yet. Click on Create project.
</CommandEmpty> </CommandEmpty>
<CommandGroup heading={"Projects"}> <CommandGroup heading={"Projects"}>
<CommandList> <CommandList>
{data?.map((project) => ( {data?.map((project) => (
<CommandItem <CommandItem
key={project.projectId} key={project.projectId}
onSelect={() => { onSelect={() => {
router.push(`/dashboard/project/${project.projectId}`); router.push(`/dashboard/project/${project.projectId}`);
setOpen(false); setOpen(false);
}} }}
> >
<BookIcon className="size-4 text-muted-foreground mr-2" /> <BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name} {project.name}
</CommandItem> </CommandItem>
))} ))}
</CommandList> </CommandList>
</CommandGroup> </CommandGroup>
<CommandSeparator /> <CommandSeparator />
<CommandGroup heading={"Services"}> <CommandGroup heading={"Services"}>
<CommandList> <CommandList>
{data?.map((project) => { {data?.map((project) => {
const applications: Services[] = extractServices(project); const applications: Services[] = extractServices(project);
return applications.map((application) => ( return applications.map((application) => (
<CommandItem <CommandItem
key={application.id} key={application.id}
onSelect={() => { onSelect={() => {
router.push( router.push(
`/dashboard/project/${project.projectId}/services/${application.type}/${application.id}` `/dashboard/project/${project.projectId}/services/${application.type}/${application.id}`,
); );
setOpen(false); setOpen(false);
}} }}
> >
{application.type === "postgres" && ( {application.type === "postgres" && (
<PostgresqlIcon className="h-6 w-6 mr-2" /> <PostgresqlIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "redis" && ( {application.type === "redis" && (
<RedisIcon className="h-6 w-6 mr-2" /> <RedisIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "mariadb" && ( {application.type === "mariadb" && (
<MariadbIcon className="h-6 w-6 mr-2" /> <MariadbIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "mongo" && ( {application.type === "mongo" && (
<MongodbIcon className="h-6 w-6 mr-2" /> <MongodbIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "mysql" && ( {application.type === "mysql" && (
<MysqlIcon className="h-6 w-6 mr-2" /> <MysqlIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "application" && ( {application.type === "application" && (
<GlobeIcon className="h-6 w-6 mr-2" /> <GlobeIcon className="h-6 w-6 mr-2" />
)} )}
{application.type === "compose" && ( {application.type === "compose" && (
<CircuitBoard className="h-6 w-6 mr-2" /> <CircuitBoard className="h-6 w-6 mr-2" />
)} )}
<span className="flex-grow"> <span className="flex-grow">
{project.name} / {application.name}{" "} {project.name} / {application.name}{" "}
<div style={{ display: "none" }}>{application.id}</div> <div style={{ display: "none" }}>{application.id}</div>
</span> </span>
<div> <div>
<StatusTooltip status={application.status} /> <StatusTooltip status={application.status} />
</div> </div>
</CommandItem> </CommandItem>
)); ));
})} })}
</CommandList> </CommandList>
</CommandGroup> </CommandGroup>
<CommandSeparator /> <CommandSeparator />
<CommandGroup heading={"Application"} hidden={true}> <CommandGroup heading={"Application"} hidden={true}>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/projects"); router.push("/dashboard/projects");
setOpen(false); setOpen(false);
}} }}
> >
Projects Projects
</CommandItem> </CommandItem>
{!isCloud && ( {!isCloud && (
<> <>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/monitoring"); router.push("/dashboard/monitoring");
setOpen(false); setOpen(false);
}} }}
> >
Monitoring Monitoring
</CommandItem> </CommandItem>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/traefik"); router.push("/dashboard/traefik");
setOpen(false); setOpen(false);
}} }}
> >
Traefik Traefik
</CommandItem> </CommandItem>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/docker"); router.push("/dashboard/docker");
setOpen(false); setOpen(false);
}} }}
> >
Docker Docker
</CommandItem> </CommandItem>
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/requests"); router.push("/dashboard/requests");
setOpen(false); setOpen(false);
}} }}
> >
Requests Requests
</CommandItem> </CommandItem>
</> </>
)} )}
<CommandItem <CommandItem
onSelect={() => { onSelect={() => {
router.push("/dashboard/settings/server"); router.push("/dashboard/settings/server");
setOpen(false); setOpen(false);
}} }}
> >
Settings Settings
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</CommandDialog> </CommandDialog>
</div> </div>
); );
}; };

View File

@@ -45,6 +45,9 @@ import { z } from "zod";
const certificateDataHolder = const certificateDataHolder =
"-----BEGIN CERTIFICATE-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n------END CERTIFICATE-----"; "-----BEGIN CERTIFICATE-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n------END CERTIFICATE-----";
const privateKeyDataHolder =
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
const addCertificate = z.object({ const addCertificate = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
certificateData: z.string().min(1, "Certificate data is required"), certificateData: z.string().min(1, "Certificate data is required"),
@@ -154,7 +157,7 @@ export const AddCertificate = () => {
<FormControl> <FormControl>
<Textarea <Textarea
className="h-32" className="h-32"
placeholder={certificateDataHolder} placeholder={privateKeyDataHolder}
{...field} {...field}
/> />
</FormControl> </FormControl>

View File

@@ -6,13 +6,144 @@ import {
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { ShieldCheck } from "lucide-react"; import { AlertCircle, Link, ShieldCheck } from "lucide-react";
import { AddCertificate } from "./add-certificate"; import { AddCertificate } from "./add-certificate";
import { DeleteCertificate } from "./delete-certificate"; import { DeleteCertificate } from "./delete-certificate";
export const ShowCertificates = () => { export const ShowCertificates = () => {
const { data } = api.certificates.all.useQuery(); const { data } = api.certificates.all.useQuery();
const extractExpirationDate = (certData: string): Date | null => {
try {
const match = certData.match(
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/,
);
if (!match?.[1]) return null;
const base64Cert = match[1].replace(/\s/g, "");
const binaryStr = window.atob(base64Cert);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
let dateFound = 0;
for (let i = 0; i < bytes.length - 2; i++) {
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
const dateType = bytes[i];
const dateLength = bytes[i + 1];
if (typeof dateLength === "undefined") continue;
if (dateFound === 0) {
dateFound++;
i += dateLength + 1;
continue;
}
let dateStr = "";
for (let j = 0; j < dateLength; j++) {
const charCode = bytes[i + 2 + j];
if (typeof charCode === "undefined") continue;
dateStr += String.fromCharCode(charCode);
}
if (dateType === 0x17) {
// UTCTime (YYMMDDhhmmssZ)
const year = Number.parseInt(dateStr.slice(0, 2));
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
return new Date(
Date.UTC(
fullYear,
Number.parseInt(dateStr.slice(2, 4)) - 1,
Number.parseInt(dateStr.slice(4, 6)),
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
),
);
}
// GeneralizedTime (YYYYMMDDhhmmssZ)
return new Date(
Date.UTC(
Number.parseInt(dateStr.slice(0, 4)),
Number.parseInt(dateStr.slice(4, 6)) - 1,
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
Number.parseInt(dateStr.slice(12, 14)),
),
);
}
}
return null;
} catch (error) {
console.error("Error parsing certificate:", error);
return null;
}
};
const getExpirationStatus = (certData: string) => {
const expirationDate = extractExpirationDate(certData);
if (!expirationDate)
return {
status: "unknown" as const,
className: "text-muted-foreground",
message: "Could not determine expiration",
};
const now = new Date();
const daysUntilExpiration = Math.ceil(
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
if (daysUntilExpiration < 0) {
return {
status: "expired" as const,
className: "text-red-500",
message: `Expired on ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
}
if (daysUntilExpiration <= 30) {
return {
status: "warning" as const,
className: "text-yellow-500",
message: `Expires in ${daysUntilExpiration} days`,
};
}
return {
status: "valid" as const,
className: "text-muted-foreground",
message: `Expires ${expirationDate.toLocaleDateString([], {
year: "numeric",
month: "long",
day: "numeric",
})}`,
};
};
const getCertificateChainInfo = (certData: string) => {
const certCount = (certData.match(/-----BEGIN CERTIFICATE-----/g) || [])
.length;
return certCount > 1
? {
isChain: true,
count: certCount,
}
: {
isChain: false,
count: 1,
};
};
return ( return (
<div className="h-full"> <div className="h-full">
<Card className="bg-transparent h-full"> <Card className="bg-transparent h-full">
@@ -23,7 +154,7 @@ export const ShowCertificates = () => {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 pt-4 h-full"> <CardContent className="space-y-2 pt-4 h-full">
{data?.length === 0 ? ( {!data?.length ? (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<ShieldCheck className="size-8 self-center text-muted-foreground" /> <ShieldCheck className="size-8 self-center text-muted-foreground" />
<span className="text-base text-muted-foreground"> <span className="text-base text-muted-foreground">
@@ -35,21 +166,53 @@ export const ShowCertificates = () => {
) : ( ) : (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{data?.map((destination, index) => ( {data.map((certificate, index) => {
<div const expiration = getExpirationStatus(
key={destination.certificateId} certificate.certificateData,
className="flex items-center justify-between border p-4 rounded-lg" );
> const chainInfo = getCertificateChainInfo(
<span className="text-sm text-muted-foreground"> certificate.certificateData,
{index + 1}. {destination.name} );
</span> return (
<div className="flex flex-row gap-3"> <div
<DeleteCertificate key={certificate.certificateId}
certificateId={destination.certificateId} className="flex flex-col border p-4 rounded-lg space-y-2"
/> >
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{index + 1}. {certificate.name}
</span>
{chainInfo.isChain && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
<Link className="size-3 text-muted-foreground" />
<span className="text-xs text-muted-foreground">
Chain ({chainInfo.count})
</span>
</div>
)}
</div>
<DeleteCertificate
certificateId={certificate.certificateId}
/>
</div>
<div
className={`text-xs flex items-center gap-1.5 ${expiration.className}`}
>
{expiration.status !== "valid" && (
<AlertCircle className="size-3" />
)}
{expiration.message}
{certificate.autoRenew &&
expiration.status !== "valid" && (
<span className="text-xs text-emerald-500 ml-1">
(Auto-renewal enabled)
</span>
)}
</div>
</div> </div>
</div> );
))} })}
</div> </div>
<div> <div>
<AddCertificate /> <AddCertificate />

View File

@@ -1,3 +1,4 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -33,7 +34,13 @@ export const ShowNodeData = ({ data }: Props) => {
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card"> <div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card">
<code> <code>
<pre className="whitespace-pre-wrap break-words"> <pre className="whitespace-pre-wrap break-words">
{JSON.stringify(data, null, 2)} <CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre> </pre>
</code> </code>
</div> </div>

View File

@@ -159,7 +159,11 @@ export const AddRegistry = () => {
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Username" {...field} /> <Input
placeholder="Username"
autoComplete="off"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -177,6 +181,7 @@ export const AddRegistry = () => {
<FormControl> <FormControl>
<Input <Input
placeholder="Password" placeholder="Password"
autoComplete="off"
{...field} {...field}
type="password" type="password"
/> />

View File

@@ -64,6 +64,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
.object({ .object({
type: z.literal("discord"), type: z.literal("discord"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }), webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
decoration: z.boolean().default(true),
}) })
.merge(notificationBaseSchema), .merge(notificationBaseSchema),
z z
@@ -195,6 +196,7 @@ export const AddNotification = () => {
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
webhookUrl: data.webhookUrl, webhookUrl: data.webhookUrl,
decoration: data.decoration,
name: data.name, name: data.name,
dockerCleanup: dockerCleanup, dockerCleanup: dockerCleanup,
}); });
@@ -397,23 +399,47 @@ export const AddNotification = () => {
)} )}
{type === "discord" && ( {type === "discord" && (
<FormField <>
control={form.control} <FormField
name="webhookUrl" control={form.control}
render={({ field }) => ( name="webhookUrl"
<FormItem> render={({ field }) => (
<FormLabel>Webhook URL</FormLabel> <FormItem>
<FormControl> <FormLabel>Webhook URL</FormLabel>
<Input <FormControl>
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ" <Input
{...field} placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
/> {...field}
</FormControl> />
</FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="decoration"
defaultValue={true}
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Decoration</FormLabel>
<FormDescription>
Decorate the notification with emojis.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)} )}
{type === "email" && ( {type === "email" && (
@@ -708,6 +734,7 @@ export const AddNotification = () => {
} else if (type === "discord") { } else if (type === "discord") {
await testDiscordConnection({ await testDiscordConnection({
webhookUrl: form.getValues("webhookUrl"), webhookUrl: form.getValues("webhookUrl"),
decoration: form.getValues("decoration"),
}); });
} else if (type === "email") { } else if (type === "email") {
await testEmailConnection({ await testEmailConnection({

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

View File

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

View File

@@ -26,9 +26,9 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Mail, PenBoxIcon } from "lucide-react"; import { Mail, Pen } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FieldErrors, useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
type NotificationSchema, type NotificationSchema,
@@ -113,6 +113,7 @@ export const UpdateNotification = ({ notificationId }: Props) => {
databaseBackup: data.databaseBackup, databaseBackup: data.databaseBackup,
type: data.notificationType, type: data.notificationType,
webhookUrl: data.discord?.webhookUrl, webhookUrl: data.discord?.webhookUrl,
decoration: data.discord?.decoration || undefined,
name: data.name, name: data.name,
dockerCleanup: data.dockerCleanup, dockerCleanup: data.dockerCleanup,
}); });
@@ -178,6 +179,7 @@ export const UpdateNotification = ({ notificationId }: Props) => {
dokployRestart: dokployRestart, dokployRestart: dokployRestart,
databaseBackup: databaseBackup, databaseBackup: databaseBackup,
webhookUrl: formData.webhookUrl, webhookUrl: formData.webhookUrl,
decoration: formData.decoration,
name: formData.name, name: formData.name,
notificationId: notificationId, notificationId: notificationId,
discordId: data?.discordId, discordId: data?.discordId,
@@ -218,8 +220,12 @@ 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
<PenBoxIcon className="size-4 text-muted-foreground" /> variant="ghost"
size="icon"
className="h-9 w-9 dark:hover:bg-zinc-900/80 hover:bg-gray-200/80"
>
<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">
@@ -356,23 +362,46 @@ export const UpdateNotification = ({ notificationId }: Props) => {
)} )}
{type === "discord" && ( {type === "discord" && (
<FormField <>
control={form.control} <FormField
name="webhookUrl" control={form.control}
render={({ field }) => ( name="webhookUrl"
<FormItem> render={({ field }) => (
<FormLabel>Webhook URL</FormLabel> <FormItem>
<FormControl> <FormLabel>Webhook URL</FormLabel>
<Input <FormControl>
placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ" <Input
{...field} placeholder="https://discord.com/api/webhooks/123456789/ABCDEFGHIJKLMNOPQRSTUVWXYZ"
/> {...field}
</FormControl> />
</FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="decoration"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Decoration</FormLabel>
<FormDescription>
Decorate the notification with emojis.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</>
)} )}
{type === "email" && ( {type === "email" && (
<> <>
@@ -667,6 +696,7 @@ export const UpdateNotification = ({ notificationId }: Props) => {
} else if (type === "discord") { } else if (type === "discord") {
await testDiscordConnection({ await testDiscordConnection({
webhookUrl: form.getValues("webhookUrl"), webhookUrl: form.getValues("webhookUrl"),
decoration: form.getValues("decoration"),
}); });
} else if (type === "email") { } else if (type === "email") {
await testEmailConnection({ await testEmailConnection({

View File

@@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -26,7 +27,6 @@ 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(),
@@ -104,6 +104,7 @@ export const ProfileForm = () => {
.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");

View File

@@ -1,3 +1,5 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -18,13 +20,11 @@ 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 { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { DialogAction } from "@/components/shared/dialog-action";
import { AlertBlock } from "@/components/shared/alert-block";
import { useRouter } from "next/router";
const profileSchema = z.object({ const profileSchema = z.object({
password: z.string().min(1, { password: z.string().min(1, {

View File

@@ -25,6 +25,7 @@ import { toast } from "sonner";
import { cn } from "@/lib/utils"; 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 { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
import { ShowModalLogs } from "../../web-server/show-modal-logs"; import { ShowModalLogs } from "../../web-server/show-modal-logs";
interface Props { interface Props {
@@ -128,6 +129,14 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
<span>Enter the terminal</span> <span>Enter the terminal</span>
</DropdownMenuItem> </DropdownMenuItem>
</DockerTerminalModal> */} </DockerTerminalModal> */}
<ManageTraefikPorts serverId={serverId}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
</DropdownMenuItem>
</ManageTraefikPorts>
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -108,7 +108,8 @@ export const EditScript = ({ serverId }: Props) => {
</DialogDescription> </DialogDescription>
<AlertBlock type="warning"> <AlertBlock type="warning">
We recommend not modifying this script unless you know what you are doing. We recommend not modifying this script unless you know what you are
doing.
</AlertBlock> </AlertBlock>
</DialogHeader> </DialogHeader>
<div className="grid gap-4"> <div className="grid gap-4">

View File

@@ -34,8 +34,8 @@ import { toast } from "sonner";
import { ShowDeployment } from "../../application/deployments/show-deployment"; import { ShowDeployment } from "../../application/deployments/show-deployment";
import { EditScript } from "./edit-script"; import { EditScript } from "./edit-script";
import { GPUSupport } from "./gpu-support"; import { GPUSupport } from "./gpu-support";
import { ValidateServer } from "./validate-server";
import { SecurityAudit } from "./security-audit"; import { SecurityAudit } from "./security-audit";
import { ValidateServer } from "./validate-server";
interface Props { interface Props {
serverId: string; serverId: string;

View File

@@ -23,15 +23,16 @@ import { api } from "@/utils/api";
import { format } from "date-fns"; import { format } from "date-fns";
import { KeyIcon, MoreHorizontal, ServerIcon } from "lucide-react"; import { KeyIcon, MoreHorizontal, ServerIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { toast } from "sonner"; import { toast } from "sonner";
import { TerminalModal } from "../web-server/terminal-modal"; import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions"; import { ShowServerActions } from "./actions/show-server-actions";
import { AddServer } from "./add-server"; import { AddServer } from "./add-server";
import { SetupServer } from "./setup-server"; import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal"; import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-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"; import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
export const ShowServers = () => { export const ShowServers = () => {
@@ -259,6 +260,9 @@ export const ShowServers = () => {
<ShowDockerContainersModal <ShowDockerContainersModal
serverId={server.serverId} serverId={server.serverId}
/> />
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
</> </>
)} )}
</DropdownMenuContent> </DropdownMenuContent>

View File

@@ -0,0 +1,51 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ContainerIcon } from "lucide-react";
import { useState } from "react";
import SwarmMonitorCard from "../../swarm/monitoring-card";
interface Props {
serverId: string;
}
export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Swarm Overview
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl overflow-y-auto max-h-screen ">
<DialogHeader>
<div className="flex flex-col gap-1.5">
<DialogTitle className="flex items-center gap-2">
<ContainerIcon className="size-5" />
Swarm Overview
</DialogTitle>
<p className="text-muted-foreground text-sm">
See all details of your swarm node
</p>
</div>
</DialogHeader>
<div className="grid w-full gap-1">
<div className="flex flex-wrap gap-4 py-4">
<SwarmMonitorCard serverId={serverId} />
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,12 +1,12 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { ExternalLinkIcon, Loader2 } from "lucide-react";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { ExternalLinkIcon, Loader2 } from "lucide-react";
import { CopyIcon } from "lucide-react"; import { CopyIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { CodeEditor } from "@/components/shared/code-editor";
import Link from "next/link";
export const CreateSSHKey = () => { export const CreateSSHKey = () => {
const { data, refetch } = api.sshKey.all.useQuery(); const { data, refetch } = api.sshKey.all.useQuery();

View File

@@ -5,26 +5,26 @@ import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card"; } 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 { Label } from "@/components/ui/label";
import { import {
Select, Select,
SelectTrigger,
SelectValue,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel, SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { api } from "@/utils/api";
import { RocketIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { EditScript } from "../edit-script";
export const Setup = () => { export const Setup = () => {
const { data: servers } = api.server.all.useQuery(); const { data: servers } = api.server.all.useQuery();

View File

@@ -1,27 +1,27 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent,
CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card"; } 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 { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { Loader2, PcCase, RefreshCw } from "lucide-react";
import { useState } from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import { import {
Select, Select,
SelectTrigger,
SelectValue,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel, SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { StatusRow } from "../gpu-support"; import { StatusRow } from "../gpu-support";
import { AlertBlock } from "@/components/shared/alert-block";
export const Verify = () => { export const Verify = () => {
const { data: servers } = api.server.all.useQuery(); const { data: servers } = api.server.all.useQuery();

View File

@@ -1,3 +1,5 @@
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -7,21 +9,19 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { defineStepper } from "@stepperize/react";
import { BookIcon, Puzzle } from "lucide-react"; import { BookIcon, Puzzle } from "lucide-react";
import { Code2, Database, GitMerge, Globe, Plug, Users } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { defineStepper } from "@stepperize/react";
import React from "react"; import React from "react";
import { Separator } from "@/components/ui/separator"; import ConfettiExplosion from "react-confetti-explosion";
import { AlertBlock } from "@/components/shared/alert-block";
import { CreateServer } from "./create-server"; import { CreateServer } from "./create-server";
import { CreateSSHKey } from "./create-ssh-key"; import { CreateSSHKey } from "./create-ssh-key";
import { Setup } from "./setup"; import { Setup } from "./setup";
import { Verify } from "./verify"; 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( export const { useStepper, steps, Scoped } = defineStepper(
{ {

View File

@@ -80,7 +80,10 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
return ( return (
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}> <Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-7xl"> <DialogContent
className="max-h-[85vh] overflow-y-auto sm:max-w-7xl"
onEscapeKeyDown={(event) => event.preventDefault()}
>
<DialogHeader> <DialogHeader>
<DialogTitle>Docker Terminal</DialogTitle> <DialogTitle>Docker Terminal</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -119,7 +122,7 @@ export const DockerTerminalModal = ({ children, appName, serverId }: Props) => {
containerId={containerId || "select-a-container"} containerId={containerId || "select-a-container"}
/> />
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}> <Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent> <DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
Are you sure you want to close the terminal? Are you sure you want to close the terminal?

View File

@@ -80,8 +80,10 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl"> <DialogContent className="max-h-screen overflow-y-auto sm:max-w-4xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Update Traefik Env</DialogTitle> <DialogTitle>Update Traefik Environment</DialogTitle>
<DialogDescription>Update the traefik env</DialogDescription> <DialogDescription>
Update the traefik environment variables
</DialogDescription>
</DialogHeader> </DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>} {isError && <AlertBlock type="error">{error?.message}</AlertBlock>}

View File

@@ -0,0 +1,303 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "next-i18next";
import type React from "react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
interface Props {
children: React.ReactNode;
serverId?: string;
}
const PortSchema = z.object({
targetPort: z.number().min(1, "Target port is required"),
publishedPort: z.number().min(1, "Published port is required"),
publishMode: z.enum(["ingress", "host"]),
});
const TraefikPortsSchema = z.object({
ports: z.array(PortSchema),
});
type TraefikPortsForm = z.infer<typeof TraefikPortsSchema>;
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const form = useForm<TraefikPortsForm>({
resolver: zodResolver(TraefikPortsSchema),
defaultValues: {
ports: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "ports",
});
const { data: currentPorts, refetch: refetchPorts } =
api.settings.getTraefikPorts.useQuery({
serverId,
});
const { mutateAsync: updatePorts, isLoading } =
api.settings.updateTraefikPorts.useMutation({
onSuccess: () => {
refetchPorts();
},
});
useEffect(() => {
if (currentPorts) {
form.reset({ ports: currentPorts });
}
}, [currentPorts, form]);
const handleAddPort = () => {
append({ targetPort: 0, publishedPort: 0, publishMode: "host" });
};
const onSubmit = async (data: TraefikPortsForm) => {
try {
await updatePorts({
serverId,
additionalPorts: data.ports,
});
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-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-xl">
{t("settings.server.webServer.traefik.managePorts")}
</DialogTitle>
<DialogDescription className="text-base w-full">
<div className="flex items-center justify-between">
{t("settings.server.webServer.traefik.managePortsDescription")}
<Button
onClick={handleAddPort}
variant="default"
className="gap-2"
>
<Plus className="h-4 w-4" />
Add Mapping
</Button>
</div>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-6 py-4">
{fields.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<ArrowRightLeft className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
No port mappings configured
</span>
<p className="text-sm text-muted-foreground text-center">
Add one to get started
</p>
</div>
) : (
<div className="grid gap-4">
{fields.map((field, index) => (
<Card key={field.id}>
<CardContent className="grid grid-cols-[1fr_1fr_1.5fr_auto] gap-4 p-4 transparent">
<FormField
control={form.control}
name={`ports.${index}.targetPort`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.targetPort",
)}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
className="w-full dark:bg-black"
placeholder="e.g. 8080"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ports.${index}.publishedPort`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.publishedPort",
)}
</FormLabel>
<FormControl>
<Input
type="number"
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
className="w-full dark:bg-black"
placeholder="e.g. 80"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ports.${index}.publishMode`}
render={({ field }) => (
<FormItem>
<FormLabel className="text-sm font-medium text-muted-foreground">
{t(
"settings.server.webServer.traefik.publishMode",
)}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger className="dark:bg-black">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="host">
Host Mode
</SelectItem>
<SelectItem value="ingress">
Ingress Mode
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-end">
<Button
onClick={() => remove(index)}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{fields.length > 0 && (
<AlertBlock type="info">
<div className="flex flex-col gap-2">
<span className="text-sm">
<strong>
Each port mapping defines how external traffic reaches
your containers.
</strong>
<ul className="pt-2">
<li>
<strong>Host Mode:</strong> Directly binds the port
to the host machine.
<ul className="p-2 list-inside list-disc">
<li>
Best for single-node deployments or when you
need guaranteed port availability.
</li>
</ul>
</li>
<li>
<strong>Ingress Mode:</strong> Routes through Docker
Swarm's load balancer.
<ul className="p-2 list-inside list-disc">
<li>
Recommended for multi-node deployments and
better scalability.
</li>
</ul>
</li>
</ul>
</span>
</div>
</AlertBlock>
)}
</div>
<DialogFooter>
<Button
type="submit"
variant="default"
className="text-sm"
isLoading={isLoading}
>
Save
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
);
};
export default ManageTraefikPorts;

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
containerId={containerId || ""}
serverId={serverId}
runType="native"
/>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -4,6 +4,7 @@ import { useEffect, useRef } from "react";
import { FitAddon } from "xterm-addon-fit"; import { FitAddon } from "xterm-addon-fit";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { AttachAddon } from "@xterm/addon-attach"; import { AttachAddon } from "@xterm/addon-attach";
import { useTheme } from "next-themes";
interface Props { interface Props {
id: string; id: string;
@@ -12,7 +13,7 @@ interface Props {
export const Terminal: React.FC<Props> = ({ id, serverId }) => { export const Terminal: React.FC<Props> = ({ id, serverId }) => {
const termRef = useRef(null); const termRef = useRef(null);
const { resolvedTheme } = useTheme();
useEffect(() => { useEffect(() => {
const container = document.getElementById(id); const container = document.getElementById(id);
if (container) { if (container) {
@@ -23,8 +24,9 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
lineHeight: 1.4, lineHeight: 1.4,
convertEol: true, convertEol: true,
theme: { theme: {
cursor: "transparent", cursor: resolvedTheme === "light" ? "#000000" : "transparent",
background: "transparent", background: "rgba(0, 0, 0, 0)",
foreground: "currentColor",
}, },
}); });
const addonFit = new FitAddon(); const addonFit = new FitAddon();

View File

@@ -0,0 +1,28 @@
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useState } from "react";
export const ToggleAutoCheckUpdates = ({ disabled }: { disabled: boolean }) => {
const [enabled, setEnabled] = useState<boolean>(
localStorage.getItem("enableAutoCheckUpdates") === "true",
);
const handleToggle = (checked: boolean) => {
setEnabled(checked);
localStorage.setItem("enableAutoCheckUpdates", String(checked));
};
return (
<div className="flex items-center gap-4">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
id="autoCheckUpdatesToggle"
disabled={disabled}
/>
<Label className="text-primary" htmlFor="autoCheckUpdatesToggle">
Automatically check for new updates
</Label>
</div>
);
};

View File

@@ -3,91 +3,224 @@ import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react"; import {
Bug,
Download,
Info,
RefreshCcw,
Server,
ServerCrash,
Sparkles,
Stars,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates";
import { UpdateWebServer } from "./update-webserver"; import { UpdateWebServer } from "./update-webserver";
export const UpdateServer = () => { export const UpdateServer = () => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState<null | boolean>( const [hasCheckedUpdate, setHasCheckedUpdate] = useState(false);
null, const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
); const { mutateAsync: getUpdateData, isLoading } =
const { mutateAsync: checkAndUpdateImage, isLoading } = api.settings.getUpdateData.useMutation();
api.settings.checkAndUpdateImage.useMutation(); const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [latestVersion, setLatestVersion] = useState("");
const handleCheckUpdates = async () => {
try {
const updateData = await getUpdateData();
const versionToUpdate = updateData.latestVersion || "";
setHasCheckedUpdate(true);
setIsUpdateAvailable(updateData.updateAvailable);
setLatestVersion(versionToUpdate);
if (updateData.updateAvailable) {
toast.success(versionToUpdate, {
description: "New version available!",
});
} else {
toast.info("No updates available");
}
} catch (error) {
console.error("Error checking for updates:", error);
setHasCheckedUpdate(true);
setIsUpdateAvailable(false);
toast.error(
"An error occurred while checking for updates, please try again.",
);
}
};
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="secondary"> <Button variant="secondary" className="gap-2">
<RefreshCcw className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
Updates Updates
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:m:max-w-lg "> <DialogContent className="max-w-lg p-6">
<DialogHeader> <div className="flex items-center justify-between mb-8">
<DialogTitle>Web Server Update</DialogTitle> <DialogTitle className="text-2xl font-semibold">
<DialogDescription> Web Server Update
Check new releases and update your dokploy </DialogTitle>
</DialogDescription> {dokployVersion && (
</DialogHeader> <div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{dokployVersion} | {releaseTag}
</span>
</div>
)}
</div>
<div className="flex flex-col gap-4"> {/* Initial state */}
<span className="text-sm text-muted-foreground"> {!hasCheckedUpdate && (
We suggest to update your dokploy to the latest version only if you: <div className="mb-8">
</span> <p className="text text-muted-foreground">
<ul className="list-disc list-inside text-sm text-muted-foreground"> Check for new releases and update Dokploy.
<li>Want to try the latest features</li> <br />
<li>Some bug that is blocking to use some features</li> <br />
</ul> We recommend checking for updates regularly to ensure you have the
<AlertBlock type="info"> latest features and security improvements.
We recommend checking the latest version for any breaking changes </p>
before updating. Go to{" "} </div>
<Link )}
href="https://github.com/Dokploy/dokploy/releases"
target="_blank"
className="text-foreground"
>
Dokploy Releases
</Link>{" "}
to check the latest version.
</AlertBlock>
<div className="w-full flex flex-col gap-4"> {/* Update available state */}
{isUpdateAvailable === false && ( {isUpdateAvailable && latestVersion && (
<div className="flex flex-col items-center gap-3"> <div className="mb-8">
<RefreshCcw className="size-6 self-center text-muted-foreground" /> <div className="inline-flex items-center gap-2 rounded-lg px-3 py-2 border border-emerald-900 bg-emerald-900 dark:bg-emerald-900/40 mb-4 w-full">
<span className="text-sm text-muted-foreground"> <div className="flex items-center gap-1.5">
You are using the latest version <span className="flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
</span>
<Download className="h-4 w-4 text-emerald-400" />
<span className="text font-medium text-emerald-400 ">
New version available:
</span> </span>
</div> </div>
)} <span className="text font-semibold text-emerald-300">
{latestVersion}
</span>
</div>
<div className="space-y-4 text-muted-foreground">
<p className="text">
A new version of the server software is available. Consider
updating if you:
</p>
<ul className="space-y-3">
<li className="flex items-start gap-2">
<Stars className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
<span className="text">
Want to access the latest features and improvements
</span>
</li>
<li className="flex items-start gap-2">
<Bug className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
<span className="text">
Are experiencing issues that may be resolved in the new
version
</span>
</li>
</ul>
</div>
</div>
)}
{/* Up to date state */}
{hasCheckedUpdate && !isUpdateAvailable && !isLoading && (
<div className="mb-8">
<div className="flex flex-col items-center gap-6 mb-6">
<div className="rounded-full p-4 bg-emerald-400/40">
<Sparkles className="h-8 w-8 text-emerald-400" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">
You are using the latest version
</h3>
<p className="text text-muted-foreground">
Your server is up to date with all the latest features and
security improvements.
</p>
</div>
</div>
</div>
)}
{hasCheckedUpdate && isLoading && (
<div className="mb-8">
<div className="flex flex-col items-center gap-6 mb-6">
<div className="rounded-full p-4 bg-[#5B9DFF]/40 text-foreground">
<RefreshCcw className="h-8 w-8 animate-spin" />
</div>
<div className="text-center space-y-2">
<h3 className="text-lg font-medium">Checking for updates...</h3>
<p className="text text-muted-foreground">
Please wait while we pull the latest version information from
Docker Hub.
</p>
</div>
</div>
</div>
)}
{isUpdateAvailable && (
<div className="rounded-lg bg-[#16254D] p-4 mb-8">
<div className="flex gap-2">
<Info className="h-5 w-5 flex-shrink-0 text-[#5B9DFF]" />
<div className="text-[#5B9DFF]">
We recommend reviewing the{" "}
<Link
href="https://github.com/Dokploy/dokploy/releases"
target="_blank"
className="text-white underline hover:text-zinc-200"
>
release notes
</Link>{" "}
for any breaking changes before updating.
</div>
</div>
</div>
)}
<div className="flex items-center justify-between pt-2">
<ToggleAutoCheckUpdates disabled={isLoading} />
</div>
<div className="space-y-4 flex items-center justify-end">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
{isUpdateAvailable ? ( {isUpdateAvailable ? (
<UpdateWebServer /> <UpdateWebServer />
) : ( ) : (
<Button <Button
className="w-full" variant="secondary"
onClick={async () => { onClick={handleCheckUpdates}
await checkAndUpdateImage() disabled={isLoading}
.then(async (e) => {
setIsUpdateAvailable(e);
})
.catch(() => {
setIsUpdateAvailable(false);
toast.error("Error to check updates");
});
toast.success("Check updates");
}}
isLoading={isLoading}
> >
Check Updates {isLoading ? (
<>
<RefreshCcw className="h-4 w-4 animate-spin" />
Checking for updates
</>
) : (
<>
<RefreshCcw className="h-4 w-4" />
Check for updates
</>
)}
</Button> </Button>
)} )}
</div> </div>
@@ -96,3 +229,5 @@ export const UpdateServer = () => {
</Dialog> </Dialog>
); );
}; };
export default UpdateServer;

View File

@@ -11,24 +11,53 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { HardDriveDownload } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
export const UpdateWebServer = () => { interface Props {
isNavbar?: boolean;
}
export const UpdateWebServer = ({ isNavbar }: Props) => {
const { mutateAsync: updateServer, isLoading } = const { mutateAsync: updateServer, isLoading } =
api.settings.updateServer.useMutation(); api.settings.updateServer.useMutation();
const buttonLabel = isNavbar ? "Update available" : "Update Server";
const handleConfirm = async () => {
try {
await updateServer();
toast.success(
"The server has been updated. The page will be reloaded to reflect the changes...",
);
setTimeout(() => {
// Allow seeing the toast before reloading
window.location.reload();
}, 2000);
} catch (error) {
console.error("Error updating server:", error);
toast.error(
"An error occurred while updating the server, please try again.",
);
}
};
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
className="relative w-full" className="relative w-full"
variant="secondary" variant={isNavbar ? "outline" : "secondary"}
isLoading={isLoading} isLoading={isLoading}
> >
<span className="absolute -right-1 -top-2 flex h-3 w-3"> {!isLoading && <HardDriveDownload className="h-4 w-4" />}
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" /> {!isLoading && (
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" /> <span className="absolute -right-1 -top-2 flex h-3 w-3">
</span> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
Update Server <span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
)}
{isLoading ? "Updating..." : buttonLabel}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
@@ -36,19 +65,12 @@ export const UpdateWebServer = () => {
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will update the web server to the This action cannot be undone. This will update the web server to the
new version. new version. The page will be reloaded once the update is finished.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction onClick={handleConfirm}>Confirm</AlertDialogAction>
onClick={async () => {
await updateServer();
toast.success("Please reload the browser to see the changes");
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@@ -0,0 +1,218 @@
import type { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { ShowNodeConfig } from "../details/show-node-config";
// import { ShowContainerConfig } from "../config/show-container-config";
// import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
// import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
// import type { Container } from "./show-containers";
export interface ApplicationList {
ID: string;
Image: string;
Mode: string;
Name: string;
Ports: string;
Replicas: string;
CurrentState: string;
DesiredState: string;
Error: string;
Node: string;
}
export const columns: ColumnDef<ApplicationList>[] = [
{
accessorKey: "ID",
accessorFn: (row) => row.ID,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("ID")}</div>;
},
},
{
accessorKey: "Name",
accessorFn: (row) => row.Name,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Name")}</div>;
},
},
{
accessorKey: "Image",
accessorFn: (row) => row.Image,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Image
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Image")}</div>;
},
},
{
accessorKey: "Mode",
accessorFn: (row) => row.Mode,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Mode
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Mode")}</div>;
},
},
{
accessorKey: "CurrentState",
accessorFn: (row) => row.CurrentState,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Current State
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("CurrentState") as string;
const valueStart = value.startsWith("Running")
? "Running"
: value.startsWith("Shutdown")
? "Shutdown"
: value;
return (
<div className="capitalize">
<Badge
variant={
valueStart === "Running"
? "default"
: value === "Shutdown"
? "destructive"
: "secondary"
}
>
{value}
</Badge>
</div>
);
},
},
{
accessorKey: "DesiredState",
accessorFn: (row) => row.DesiredState,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Desired State
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("DesiredState")}</div>;
},
},
{
accessorKey: "Replicas",
accessorFn: (row) => row.Replicas,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Replicas
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Replicas")}</div>;
},
},
{
accessorKey: "Ports",
accessorFn: (row) => row.Ports,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Ports
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Ports")}</div>;
},
},
{
accessorKey: "Errors",
accessorFn: (row) => row.Error,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Errors
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return <div>{row.getValue("Errors")}</div>;
},
},
];

View File

@@ -0,0 +1,197 @@
"use client";
import {
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import { ChevronDown } from "lucide-react";
import React from "react";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({});
const [rowSelection, setRowSelection] = React.useState({});
const [pagination, setPagination] = React.useState({
pageIndex: 0, //initial page index
pageSize: 8, //default page size
});
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="mt-6 grid gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 </div>w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by name..."
value={(table.getColumn("Name")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("Name")?.setFilterValue(event.target.value)
}
className="md:max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="sm:ml-auto max-sm:w-full">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table?.getRowModel()?.rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
{/* {isLoading ? (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
Loading...
</span>
</div>
) : (
<>No results.</>
)} */}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{data && data?.length > 0 && (
<div className="flex items-center justify-end space-x-2 py-4">
<div className="space-x-2 flex flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { Layers, Loader2 } from "lucide-react";
import React from "react";
import { columns } from "./columns";
import { DataTable } from "./data-table";
interface Props {
serverId?: string;
}
interface ApplicationList {
ID: string;
Image: string;
Mode: string;
Name: string;
Ports: string;
Replicas: string;
CurrentState: string;
DesiredState: string;
Error: string;
Node: string;
}
export const ShowNodeApplications = ({ serverId }: Props) => {
const { data: NodeApps, isLoading: NodeAppsLoading } =
api.swarm.getNodeApps.useQuery({ serverId });
let applicationList = "";
if (NodeApps && NodeApps.length > 0) {
applicationList = NodeApps.map((app) => app.Name).join(" ");
}
const { data: NodeAppDetails, isLoading: NodeAppDetailsLoading } =
api.swarm.getAppInfos.useQuery({ appName: applicationList, serverId });
if (NodeAppsLoading || NodeAppDetailsLoading) {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
</Button>
</DialogTrigger>
</Dialog>
);
}
if (!NodeApps || !NodeAppDetails) {
return (
<span className="text-sm w-full flex text-center justify-center items-center">
No data found
</span>
);
}
const combinedData: ApplicationList[] = NodeApps.flatMap((app) => {
const appDetails =
NodeAppDetails?.filter((detail) =>
detail.Name.startsWith(`${app.Name}.`),
) || [];
if (appDetails.length === 0) {
return [
{
...app,
CurrentState: "N/A",
DesiredState: "N/A",
Error: "",
Node: "N/A",
Ports: app.Ports,
},
];
}
return appDetails.map((detail) => ({
...app,
CurrentState: detail.CurrentState,
DesiredState: detail.DesiredState,
Error: detail.Error,
Node: detail.Node,
Ports: detail.Ports || app.Ports,
}));
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Layers className="h-4 w-4 mr-2" />
Services
</Button>
</DialogTrigger>
<DialogContent className={"sm:max-w-6xl overflow-y-auto max-h-screen"}>
<DialogHeader>
<DialogTitle>Node Applications</DialogTitle>
<DialogDescription>
See in detail the applications running on this node
</DialogDescription>
</DialogHeader>
<div className="max-h-[80vh]">
<DataTable columns={columns} data={combinedData ?? []} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,140 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/utils/api";
import {
AlertCircle,
CheckCircle,
HelpCircle,
Loader2,
LoaderIcon,
} from "lucide-react";
import { ShowNodeApplications } from "../applications/show-applications";
import { ShowNodeConfig } from "./show-node-config";
export interface SwarmList {
ID: string;
Hostname: string;
Availability: string;
EngineVersion: string;
Status: string;
ManagerStatus: string;
TLSStatus: string;
}
interface Props {
node: SwarmList;
serverId?: string;
}
export function NodeCard({ node, serverId }: Props) {
const { data, isLoading } = api.swarm.getNodeInfo.useQuery({
nodeId: node.ID,
serverId,
});
const getStatusIcon = (status: string) => {
switch (status) {
case "Ready":
return <CheckCircle className="h-4 w-4 text-green-500" />;
case "Down":
return <AlertCircle className="h-4 w-4 text-red-500" />;
default:
return <HelpCircle className="h-4 w-4 text-yellow-500" />;
}
};
if (isLoading) {
return (
<Card className="w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-center justify-between text-lg">
<span className="flex items-center gap-2">
{getStatusIcon(node.Status)}
{node.Hostname}
</span>
<Badge variant="outline" className="text-xs">
{node.ManagerStatus || "Worker"}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</CardContent>
</Card>
);
}
return (
<Card className="w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2 text-lg">
{getStatusIcon(node.Status)}
{node.Hostname}
</span>
<Badge variant="outline" className="text-xs">
{node.ManagerStatus || "Worker"}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="font-medium">Status:</span>
<span>{node.Status}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">IP Address:</span>
{isLoading ? (
<LoaderIcon className="animate-spin" />
) : (
<span>{data?.Status?.Addr}</span>
)}
</div>
<div className="flex justify-between">
<span className="font-medium">Availability:</span>
<span>{node.Availability}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">Engine Version:</span>
<span>{node.EngineVersion}</span>
</div>
<div className="flex justify-between">
<span className="font-medium">CPU:</span>
{isLoading ? (
<LoaderIcon className="animate-spin" />
) : (
<span>
{(data?.Description?.Resources?.NanoCPUs / 1e9).toFixed(2)} GHz
</span>
)}
</div>
<div className="flex justify-between">
<span className="font-medium">Memory:</span>
{isLoading ? (
<LoaderIcon className="animate-spin" />
) : (
<span>
{(
data?.Description?.Resources?.MemoryBytes /
1024 ** 3
).toFixed(2)}{" "}
GB
</span>
)}
</div>
<div className="flex justify-between">
<span className="font-medium">TLS Status:</span>
<span>{node.TLSStatus}</span>
</div>
</div>
<div className="flex gap-2 mt-4">
<ShowNodeConfig nodeId={node.ID} serverId={serverId} />
<ShowNodeApplications serverId={serverId} />
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { Settings } from "lucide-react";
interface Props {
nodeId: string;
serverId?: string;
}
export const ShowNodeConfig = ({ nodeId, serverId }: Props) => {
const { data, isLoading } = api.swarm.getNodeInfo.useQuery({
nodeId,
serverId,
});
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Settings className="h-4 w-4 mr-2" />
Config
</Button>
</DialogTrigger>
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
<DialogHeader>
<DialogTitle>Node Config</DialogTitle>
<DialogDescription>
See in detail the metadata of this node
</DialogDescription>
</DialogHeader>
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card max-h-[70vh] overflow-auto ">
<code>
<pre className="whitespace-pre-wrap break-words items-center justify-center">
{/* {JSON.stringify(data, null, 2)} */}
<CodeEditor
language="json"
lineWrapping={false}
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,188 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import {
AlertCircle,
CheckCircle,
HelpCircle,
Loader2,
Server,
} from "lucide-react";
import { NodeCard } from "./details/details-card";
interface Props {
serverId?: string;
}
export default function SwarmMonitorCard({ serverId }: Props) {
const { data: nodes, isLoading } = api.swarm.getNodes.useQuery({
serverId,
});
if (isLoading) {
return (
<div className="w-full max-w-7xl mx-auto">
<div className="mb-6 border min-h-[55vh] rounded-lg h-full">
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
</div>
</div>
);
}
if (!nodes) {
return (
<div className="w-full max-w-7xl mx-auto">
<div className="mb-6 border min-h-[55vh] rounded-lg h-full">
<div className="flex items-center justify-center h-full text-destructive">
<span>Failed to load data</span>
</div>
</div>
</div>
);
}
const totalNodes = nodes.length;
const activeNodesCount = nodes.filter(
(node) => node.Status === "Ready",
).length;
const managerNodesCount = nodes.filter(
(node) =>
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
).length;
const activeNodes = nodes.filter((node) => node.Status === "Ready");
const managerNodes = nodes.filter(
(node) =>
node.ManagerStatus === "Leader" || node.ManagerStatus === "Reachable",
);
const getStatusIcon = (status: string) => {
switch (status) {
case "Ready":
return <CheckCircle className="h-4 w-4 text-green-500" />;
case "Down":
return <AlertCircle className="h-4 w-4 text-red-500" />;
case "Disconnected":
return <AlertCircle className="h-4 w-4 text-red-800" />;
default:
return <HelpCircle className="h-4 w-4 text-yellow-500" />;
}
};
return (
<div className="w-full max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-4">
<h1 className="text-xl font-bold">Docker Swarm Overview</h1>
{!serverId && (
<Button
type="button"
onClick={() =>
window.location.replace("/dashboard/settings/cluster")
}
>
Manage Cluster
</Button>
)}
</div>
<Card className="mb-6 bg-transparent">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-xl">
<Server className="size-4" />
Monitor
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Total Nodes:</span>
<Badge variant="secondary">{totalNodes}</Badge>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Active Nodes:</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge
variant="secondary"
className="bg-green-100 dark:bg-green-400 text-black"
>
{activeNodesCount} / {totalNodes}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{activeNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2">
{getStatusIcon(node.Status)}
{node.Hostname}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Manager Nodes:</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge
variant="secondary"
className="bg-blue-100 dark:bg-blue-400 text-black"
>
{managerNodesCount} / {totalNodes}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="max-h-48 overflow-y-auto">
{managerNodes.map((node) => (
<div key={node.ID} className="flex items-center gap-2">
{getStatusIcon(node.Status)}
{node.Hostname}
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="border-t pt-4 mt-4">
<h4 className="text-sm font-semibold mb-2">Node Status:</h4>
<ul className="space-y-2">
{nodes.map((node) => (
<li
key={node.ID}
className="flex justify-between items-center text-sm"
>
<span className="flex items-center gap-2">
{getStatusIcon(node.Status)}
{node.Hostname}
</span>
<Badge variant="outline" className="text-xs">
{node.ManagerStatus || "Worker"}
</Badge>
</li>
))}
</ul>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{nodes.map((node) => (
<NodeCard key={node.ID} node={node} serverId={serverId} />
))}
</div>
</div>
);
}

View File

@@ -12,11 +12,16 @@ import { api } from "@/utils/api";
import { HeartIcon } from "lucide-react"; import { HeartIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import { UpdateWebServer } from "../dashboard/settings/web-server/update-webserver";
import { Logo } from "../shared/logo"; import { Logo } from "../shared/logo";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { buttonVariants } from "../ui/button"; import { buttonVariants } from "../ui/button";
const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
export const Navbar = () => { export const Navbar = () => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false);
const router = useRouter(); const router = useRouter();
const { data } = api.auth.get.useQuery(); const { data } = api.auth.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -29,6 +34,59 @@ export const Navbar = () => {
}, },
); );
const { mutateAsync } = api.auth.logout.useMutation(); const { mutateAsync } = api.auth.logout.useMutation();
const { mutateAsync: getUpdateData } =
api.settings.getUpdateData.useMutation();
const checkUpdatesIntervalRef = useRef<null | NodeJS.Timeout>(null);
useEffect(() => {
// Handling of automatic check for server updates
if (isCloud) {
return;
}
if (!localStorage.getItem("enableAutoCheckUpdates")) {
// Enable auto update checking by default if user didn't change it
localStorage.setItem("enableAutoCheckUpdates", "true");
}
const clearUpdatesInterval = () => {
if (checkUpdatesIntervalRef.current) {
clearInterval(checkUpdatesIntervalRef.current);
}
};
const checkUpdates = async () => {
try {
if (localStorage.getItem("enableAutoCheckUpdates") !== "true") {
return;
}
const { updateAvailable } = await getUpdateData();
if (updateAvailable) {
// Stop interval when update is available
clearUpdatesInterval();
setIsUpdateAvailable(true);
}
} catch (error) {
console.error("Error auto-checking for updates:", error);
}
};
checkUpdatesIntervalRef.current = setInterval(
checkUpdates,
AUTO_CHECK_UPDATES_INTERVAL_MINUTES * 60000,
);
// Also check for updates on initial page load
checkUpdates();
return () => {
clearUpdatesInterval();
};
}, []);
return ( return (
<nav className="border-divider sticky inset-x-0 top-0 z-40 flex h-auto w-full items-center justify-center border-b bg-background/70 backdrop-blur-lg backdrop-saturate-150 data-[menu-open=true]:border-none data-[menu-open=true]:backdrop-blur-xl"> <nav className="border-divider sticky inset-x-0 top-0 z-40 flex h-auto w-full items-center justify-center border-b bg-background/70 backdrop-blur-lg backdrop-saturate-150 data-[menu-open=true]:border-none data-[menu-open=true]:backdrop-blur-xl">
<header className="relative z-40 flex w-full max-w-8xl flex-row flex-nowrap items-center justify-between gap-4 px-4 sm:px-6 h-16"> <header className="relative z-40 flex w-full max-w-8xl flex-row flex-nowrap items-center justify-between gap-4 px-4 sm:px-6 h-16">
@@ -43,6 +101,11 @@ export const Navbar = () => {
</span> </span>
</Link> </Link>
</div> </div>
{isUpdateAvailable && (
<div>
<UpdateWebServer isNavbar />
</div>
)}
<Link <Link
className={buttonVariants({ className={buttonVariants({
variant: "outline", variant: "outline",

View File

@@ -21,7 +21,8 @@ export type TabState =
| "settings" | "settings"
| "traefik" | "traefik"
| "requests" | "requests"
| "docker"; | "docker"
| "swarm";
const getTabMaps = (isCloud: boolean) => { const getTabMaps = (isCloud: boolean) => {
const elements: TabInfo[] = [ const elements: TabInfo[] = [
@@ -60,6 +61,15 @@ const getTabMaps = (isCloud: boolean) => {
}, },
type: "docker", type: "docker",
}, },
{
label: "Swarm",
description: "Manage your docker swarm and Servers",
index: "/dashboard/swarm",
isShow: ({ rol, user }) => {
return Boolean(rol === "admin" || user?.canAccessToDocker);
},
type: "swarm",
},
{ {
label: "Requests", label: "Requests",
description: "Manage your requests", description: "Manage your requests",

View File

@@ -14,14 +14,14 @@ 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-500/15 text-destructive text-xs h-4 px-1 py-1 rounded-md", 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: yellow:
"border-transparent select-none items-center whitespace-nowrap font-medium bg-yellow-500/15 text-yellow-500 text-xs h-4 px-1 py-1 rounded-md", "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: orange:
"border-transparent select-none items-center whitespace-nowrap font-medium bg-orange-500/15 text-orange-500 text-xs h-4 px-1 py-1 rounded-md", "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: green:
"border-transparent select-none items-center whitespace-nowrap font-medium bg-emerald-500/15 text-emerald-500 text-xs h-4 px-1 py-1 rounded-md", "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-500/15 text-blue-500 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: 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", "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,6 +62,7 @@ export const Secrets = (props: Props) => {
} }
language="properties" language="properties"
disabled={isVisible} disabled={isVisible}
lineWrapping
placeholder={props.placeholder} placeholder={props.placeholder}
className="h-96 font-mono" className="h-96 font-mono"
{...field} {...field}

View File

@@ -0,0 +1 @@
ALTER TABLE "discord" ADD COLUMN "decoration" boolean;

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,377 @@
{"version":"5","dialect":"pg","entries":[{"idx":0,"version":"5","when":1713262741218,"tag":"0000_reflective_puck","breakpoints":true},{"idx":1,"version":"5","when":1713761637676,"tag":"0001_striped_tattoo","breakpoints":true},{"idx":2,"version":"5","when":1713763492341,"tag":"0002_ambiguous_carlie_cooper","breakpoints":true},{"idx":3,"version":"5","when":1713947141424,"tag":"0003_square_lightspeed","breakpoints":true},{"idx":4,"version":"5","when":1714004732716,"tag":"0004_nice_tenebrous","breakpoints":true},{"idx":5,"version":"5","when":1715551130605,"tag":"0005_cute_terror","breakpoints":true},{"idx":6,"version":"6","when":1715563165991,"tag":"0006_oval_jimmy_woo","breakpoints":true},{"idx":7,"version":"6","when":1715563497100,"tag":"0007_cute_guardsmen","breakpoints":true},{"idx":8,"version":"6","when":1715564143641,"tag":"0008_lazy_sage","breakpoints":true},{"idx":9,"version":"6","when":1715564774423,"tag":"0009_majestic_spencer_smythe","breakpoints":true},{"idx":10,"version":"6","when":1715574037832,"tag":"0010_lean_black_widow","breakpoints":true},{"idx":11,"version":"6","when":1715574230599,"tag":"0011_petite_calypso","breakpoints":true},{"idx":12,"version":"6","when":1716015716708,"tag":"0012_chubby_umar","breakpoints":true},{"idx":13,"version":"6","when":1716076179443,"tag":"0013_blushing_starjammers","breakpoints":true},{"idx":14,"version":"6","when":1716715367982,"tag":"0014_same_hammerhead","breakpoints":true},{"idx":15,"version":"6","when":1717564517104,"tag":"0015_fearless_callisto","breakpoints":true},{"idx":16,"version":"6","when":1719109196484,"tag":"0016_chunky_leopardon","breakpoints":true},{"idx":17,"version":"6","when":1719547174326,"tag":"0017_minor_post","breakpoints":true},{"idx":18,"version":"6","when":1719928377858,"tag":"0018_careful_killmonger","breakpoints":true},{"idx":19,"version":"6","when":1721110706912,"tag":"0019_heavy_freak","breakpoints":true},{"idx":20,"version":"6","when":1721363861686,"tag":"0020_fantastic_slapstick","breakpoints":true},{"idx":21,"version":"6","when":1721370423752,"tag":"0021_premium_sebastian_shaw","breakpoints":true},{"idx":22,"version":"6","when":1721531163852,"tag":"0022_warm_colonel_america","breakpoints":true},{"idx":23,"version":"6","when":1721542782659,"tag":"0023_icy_maverick","breakpoints":true},{"idx":24,"version":"6","when":1721603595092,"tag":"0024_dapper_supernaut","breakpoints":true},{"idx":25,"version":"6","when":1721633853118,"tag":"0025_lying_mephisto","breakpoints":true},{"idx":26,"version":"6","when":1721979220929,"tag":"0026_known_dormammu","breakpoints":true},{"idx":27,"version":"6","when":1722445099203,"tag":"0027_red_lady_bullseye","breakpoints":true},{"idx":28,"version":"6","when":1722503439951,"tag":"0028_jittery_eternity","breakpoints":true},{"idx":29,"version":"6","when":1722578386823,"tag":"0029_colossal_zodiak","breakpoints":true},{"idx":30,"version":"6","when":1723608499147,"tag":"0030_little_kabuki","breakpoints":true},{"idx":31,"version":"6","when":1723701656243,"tag":"0031_steep_vulture","breakpoints":true},{"idx":32,"version":"6","when":1723705257806,"tag":"0032_flashy_shadow_king","breakpoints":true},{"idx":33,"version":"6","when":1725250322137,"tag":"0033_white_hawkeye","breakpoints":true},{"idx":34,"version":"6","when":1725256397019,"tag":"0034_aspiring_secret_warriors","breakpoints":true},{"idx":35,"version":"6","when":1725429324584,"tag":"0035_cool_gravity","breakpoints":true},{"idx":36,"version":"6","when":1725519351871,"tag":"0036_tired_ronan","breakpoints":true},{"idx":37,"version":"6","when":1726988289562,"tag":"0037_legal_namor","breakpoints":true},{"idx":38,"version":"6","when":1727942090102,"tag":"0038_rapid_landau","breakpoints":true},{"idx":39,"version":"6","when":1728021127765,"tag":"0039_many_tiger_shark","breakpoints":true},{"idx":40,"version":"6","when":1728780577084,"tag":"0040_graceful_wolfsbane","breakpoints":true},{"idx":41,"version":"6","when":1729667438853,"tag":"0041_huge_bruce_banner","breakpoints":true},{"idx":42,"version":"6","when":1729984439862,"tag":"0042_fancy_havok","breakpoints":true},{"idx":43,"version":"6","when":1731873965888,"tag":"0043_closed_naoko","breakpoints":true},{"idx":44,"version":"6","when":1731875539532,"tag":"0044_sour_true_believers","breakpoints":true},{"idx":45,"version":"6","when":1732644181718,"tag":"0045_smiling_blur","breakpoints":true},{"idx":46,"version":"6","when":1732851191048,"tag":"0046_purple_sleeper","breakpoints":true},{"idx":47,"version":"6","when":1733599090582,"tag":"0047_tidy_revanche","breakpoints":true},{"idx":48,"version":"6","when":1733599163710,"tag":"0048_flat_expediter","breakpoints":true},{"idx":49,"version":"6","when":1733628762978,"tag":"0049_dark_leopardon","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}]} {
"version": "5",
"dialect": "pg",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1713262741218,
"tag": "0000_reflective_puck",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1713761637676,
"tag": "0001_striped_tattoo",
"breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1713763492341,
"tag": "0002_ambiguous_carlie_cooper",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1713947141424,
"tag": "0003_square_lightspeed",
"breakpoints": true
},
{
"idx": 4,
"version": "5",
"when": 1714004732716,
"tag": "0004_nice_tenebrous",
"breakpoints": true
},
{
"idx": 5,
"version": "5",
"when": 1715551130605,
"tag": "0005_cute_terror",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1715563165991,
"tag": "0006_oval_jimmy_woo",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1715563497100,
"tag": "0007_cute_guardsmen",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1715564143641,
"tag": "0008_lazy_sage",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1715564774423,
"tag": "0009_majestic_spencer_smythe",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1715574037832,
"tag": "0010_lean_black_widow",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1715574230599,
"tag": "0011_petite_calypso",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1716015716708,
"tag": "0012_chubby_umar",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1716076179443,
"tag": "0013_blushing_starjammers",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1716715367982,
"tag": "0014_same_hammerhead",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1717564517104,
"tag": "0015_fearless_callisto",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1719109196484,
"tag": "0016_chunky_leopardon",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1719547174326,
"tag": "0017_minor_post",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1719928377858,
"tag": "0018_careful_killmonger",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1721110706912,
"tag": "0019_heavy_freak",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1721363861686,
"tag": "0020_fantastic_slapstick",
"breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1721370423752,
"tag": "0021_premium_sebastian_shaw",
"breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1721531163852,
"tag": "0022_warm_colonel_america",
"breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1721542782659,
"tag": "0023_icy_maverick",
"breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1721603595092,
"tag": "0024_dapper_supernaut",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1721633853118,
"tag": "0025_lying_mephisto",
"breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1721979220929,
"tag": "0026_known_dormammu",
"breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1722445099203,
"tag": "0027_red_lady_bullseye",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1722503439951,
"tag": "0028_jittery_eternity",
"breakpoints": true
},
{
"idx": 29,
"version": "6",
"when": 1722578386823,
"tag": "0029_colossal_zodiak",
"breakpoints": true
},
{
"idx": 30,
"version": "6",
"when": 1723608499147,
"tag": "0030_little_kabuki",
"breakpoints": true
},
{
"idx": 31,
"version": "6",
"when": 1723701656243,
"tag": "0031_steep_vulture",
"breakpoints": true
},
{
"idx": 32,
"version": "6",
"when": 1723705257806,
"tag": "0032_flashy_shadow_king",
"breakpoints": true
},
{
"idx": 33,
"version": "6",
"when": 1725250322137,
"tag": "0033_white_hawkeye",
"breakpoints": true
},
{
"idx": 34,
"version": "6",
"when": 1725256397019,
"tag": "0034_aspiring_secret_warriors",
"breakpoints": true
},
{
"idx": 35,
"version": "6",
"when": 1725429324584,
"tag": "0035_cool_gravity",
"breakpoints": true
},
{
"idx": 36,
"version": "6",
"when": 1725519351871,
"tag": "0036_tired_ronan",
"breakpoints": true
},
{
"idx": 37,
"version": "6",
"when": 1726988289562,
"tag": "0037_legal_namor",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1727942090102,
"tag": "0038_rapid_landau",
"breakpoints": true
},
{
"idx": 39,
"version": "6",
"when": 1728021127765,
"tag": "0039_many_tiger_shark",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1728780577084,
"tag": "0040_graceful_wolfsbane",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1729667438853,
"tag": "0041_huge_bruce_banner",
"breakpoints": true
},
{
"idx": 42,
"version": "6",
"when": 1729984439862,
"tag": "0042_fancy_havok",
"breakpoints": true
},
{
"idx": 43,
"version": "6",
"when": 1731873965888,
"tag": "0043_closed_naoko",
"breakpoints": true
},
{
"idx": 44,
"version": "6",
"when": 1731875539532,
"tag": "0044_sour_true_believers",
"breakpoints": true
},
{
"idx": 45,
"version": "6",
"when": 1732644181718,
"tag": "0045_smiling_blur",
"breakpoints": true
},
{
"idx": 46,
"version": "6",
"when": 1732851191048,
"tag": "0046_purple_sleeper",
"breakpoints": true
},
{
"idx": 47,
"version": "6",
"when": 1733599090582,
"tag": "0047_tidy_revanche",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1733599163710,
"tag": "0048_flat_expediter",
"breakpoints": true
},
{
"idx": 49,
"version": "6",
"when": 1733628762978,
"tag": "0049_dark_leopardon",
"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
},
{
"idx": 52,
"version": "6",
"when": 1734809337308,
"tag": "0052_bumpy_luckman",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.15.0", "version": "v0.15.1",
"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",
@@ -64,6 +62,7 @@
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@stepperize/react": "4.0.1",
"@stripe/stripe-js": "4.8.0", "@stripe/stripe-js": "4.8.0",
"@tanstack/react-query": "^4.36.1", "@tanstack/react-query": "^4.36.1",
"@tanstack/react-table": "^8.16.0", "@tanstack/react-table": "^8.16.0",
@@ -87,6 +86,7 @@
"dotenv": "16.4.5", "dotenv": "16.4.5",
"drizzle-orm": "^0.30.8", "drizzle-orm": "^0.30.8",
"drizzle-zod": "0.5.1", "drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"i18next": "^23.16.4", "i18next": "^23.16.4",
"input-otp": "^1.2.4", "input-otp": "^1.2.4",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@@ -104,6 +104,7 @@
"postgres": "3.4.4", "postgres": "3.4.4",
"public-ip": "6.0.2", "public-ip": "6.0.2",
"react": "18.2.0", "react": "18.2.0",
"react-confetti-explosion": "2.1.2",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "^7.49.3", "react-hook-form": "^7.49.3",
"react-i18next": "^15.1.0", "react-i18next": "^15.1.0",

View File

@@ -3,19 +3,19 @@ import { applications, compose, github } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types"; import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup"; import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy"; import { deploy } from "@/server/utils/deploy";
import { generateRandomDomain } from "@/templates/utils";
import { import {
createPreviewDeployment,
type Domain, type Domain,
IS_CLOUD,
createPreviewDeployment,
findPreviewDeploymentByApplicationId, findPreviewDeploymentByApplicationId,
findPreviewDeploymentsByPullRequestId, findPreviewDeploymentsByPullRequestId,
IS_CLOUD,
removePreviewDeployment, removePreviewDeployment,
} from "@dokploy/server"; } from "@dokploy/server";
import { Webhooks } from "@octokit/webhooks"; import { Webhooks } from "@octokit/webhooks";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { extractCommitMessage, extractHash } from "./[refreshToken]"; import { extractCommitMessage, extractHash } from "./[refreshToken]";
import { generateRandomDomain } from "@/templates/utils";
export default async function handler( export default async function handler(
req: NextApiRequest, req: NextApiRequest,

View File

@@ -6,6 +6,7 @@ import { ShowDomainsCompose } from "@/components/dashboard/compose/domains/show-
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 { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show"; import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
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";
import { ProjectLayout } from "@/components/layouts/project-layout"; import { ProjectLayout } from "@/components/layouts/project-layout";
@@ -251,11 +252,18 @@ const Service = (
<TabsContent value="logs"> <TabsContent value="logs">
<div className="flex flex-col gap-4 pt-2.5"> <div className="flex flex-col gap-4 pt-2.5">
<ShowDockerLogsCompose {data?.composeType === "docker-compose" ? (
serverId={data?.serverId || ""} <ShowDockerLogsCompose
appName={data?.appName || ""} serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"} appName={data?.appName || ""}
/> appType={data?.composeType || "docker-compose"}
/>
) : (
<ShowDockerLogsStack
serverId={data?.serverId || ""}
appName={data?.appName || ""}
/>
)}
</div> </div>
</TabsContent> </TabsContent>

View File

@@ -0,0 +1,86 @@
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
const Dashboard = () => {
return (
<>
<div className="flex flex-wrap gap-4 py-4">
<SwarmMonitorCard />
</div>
</>
);
};
export default Dashboard;
Dashboard.getLayout = (page: ReactElement) => {
return <DashboardLayout tab={"swarm"}>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { user, session } = await validateRequest(ctx.req, ctx.res);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const { req, res } = ctx;
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session,
user: user,
},
transformer: superjson,
});
try {
await helpers.project.all.prefetch();
const auth = await helpers.auth.get.fetch();
if (auth.rol === "user") {
const user = await helpers.user.byAuthId.fetch({
authId: auth.id,
});
if (!user.canAccessToDocker) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
}
return {
props: {
trpcState: helpers.dehydrate(),
},
};
} catch (error) {
return {
props: {},
};
}
}

View File

@@ -4,10 +4,10 @@
"settings.server.domain.description": "Add a domain to your server application.", "settings.server.domain.description": "Add a domain to your server application.",
"settings.server.domain.form.domain": "Domain", "settings.server.domain.form.domain": "Domain",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email", "settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email",
"settings.server.domain.form.certificate.label": "Certificate", "settings.server.domain.form.certificate.label": "Certificate Provider",
"settings.server.domain.form.certificate.placeholder": "Select a certificate", "settings.server.domain.form.certificate.placeholder": "Select a certificate",
"settings.server.domain.form.certificateOptions.none": "None", "settings.server.domain.form.certificateOptions.none": "None",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Default)", "settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Web Server", "settings.server.webServer.title": "Web Server",
"settings.server.webServer.description": "Reload or clean the web server.", "settings.server.webServer.description": "Reload or clean the web server.",
@@ -17,7 +17,15 @@
"settings.server.webServer.updateServerIp": "Update Server IP", "settings.server.webServer.updateServerIp": "Update Server IP",
"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 Environment",
"settings.server.webServer.traefik.managePorts": "Additional Port Mappings",
"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 +1 @@
{} {}

View File

@@ -1,44 +1,44 @@
{ {
"settings.common.save": "Salva", "settings.common.save": "Salva",
"settings.server.domain.title": "Dominio del server", "settings.server.domain.title": "Dominio del server",
"settings.server.domain.description": "Aggiungi un dominio alla tua applicazione server.", "settings.server.domain.description": "Aggiungi un dominio alla tua applicazione server.",
"settings.server.domain.form.domain": "Dominio", "settings.server.domain.form.domain": "Dominio",
"settings.server.domain.form.letsEncryptEmail": "Email di Let's Encrypt", "settings.server.domain.form.letsEncryptEmail": "Email di Let's Encrypt",
"settings.server.domain.form.certificate.label": "Certificato", "settings.server.domain.form.certificate.label": "Certificato",
"settings.server.domain.form.certificate.placeholder": "Seleziona un certificato", "settings.server.domain.form.certificate.placeholder": "Seleziona un certificato",
"settings.server.domain.form.certificateOptions.none": "Nessuno", "settings.server.domain.form.certificateOptions.none": "Nessuno",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Predefinito)", "settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt (Predefinito)",
"settings.server.webServer.title": "Server Web", "settings.server.webServer.title": "Server Web",
"settings.server.webServer.description": "Ricarica o pulisci il server web.", "settings.server.webServer.description": "Ricarica o pulisci il server web.",
"settings.server.webServer.actions": "Azioni", "settings.server.webServer.actions": "Azioni",
"settings.server.webServer.reload": "Ricarica", "settings.server.webServer.reload": "Ricarica",
"settings.server.webServer.watchLogs": "Guarda i log", "settings.server.webServer.watchLogs": "Guarda i log",
"settings.server.webServer.updateServerIp": "Aggiorna IP del server", "settings.server.webServer.updateServerIp": "Aggiorna IP del server",
"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": "Modifica Env", "settings.server.webServer.traefik.modifyEnv": "Modifica Env",
"settings.server.webServer.storage.label": "Spazio", "settings.server.webServer.storage.label": "Spazio",
"settings.server.webServer.storage.cleanUnusedImages": "Pulisci immagini inutilizzate", "settings.server.webServer.storage.cleanUnusedImages": "Pulisci immagini inutilizzate",
"settings.server.webServer.storage.cleanUnusedVolumes": "Pulisci volumi inutilizzati", "settings.server.webServer.storage.cleanUnusedVolumes": "Pulisci volumi inutilizzati",
"settings.server.webServer.storage.cleanStoppedContainers": "Pulisci container fermati", "settings.server.webServer.storage.cleanStoppedContainers": "Pulisci container fermati",
"settings.server.webServer.storage.cleanDockerBuilder": "Pulisci Docker Builder e sistema", "settings.server.webServer.storage.cleanDockerBuilder": "Pulisci Docker Builder e sistema",
"settings.server.webServer.storage.cleanMonitoring": "Pulisci monitoraggio", "settings.server.webServer.storage.cleanMonitoring": "Pulisci monitoraggio",
"settings.server.webServer.storage.cleanAll": "Pulisci tutto", "settings.server.webServer.storage.cleanAll": "Pulisci tutto",
"settings.profile.title": "Account", "settings.profile.title": "Account",
"settings.profile.description": "Modifica i dettagli del tuo profilo qui.", "settings.profile.description": "Modifica i dettagli del tuo profilo qui.",
"settings.profile.email": "Email", "settings.profile.email": "Email",
"settings.profile.password": "Password", "settings.profile.password": "Password",
"settings.profile.avatar": "Avatar", "settings.profile.avatar": "Avatar",
"settings.appearance.title": "Aspetto", "settings.appearance.title": "Aspetto",
"settings.appearance.description": "Personalizza il tema della tua dashboard.", "settings.appearance.description": "Personalizza il tema della tua dashboard.",
"settings.appearance.theme": "Tema", "settings.appearance.theme": "Tema",
"settings.appearance.themeDescription": "Seleziona un tema per la tua dashboard", "settings.appearance.themeDescription": "Seleziona un tema per la tua dashboard",
"settings.appearance.themes.light": "Chiaro", "settings.appearance.themes.light": "Chiaro",
"settings.appearance.themes.dark": "Scuro", "settings.appearance.themes.dark": "Scuro",
"settings.appearance.themes.system": "Sistema", "settings.appearance.themes.system": "Sistema",
"settings.appearance.language": "Lingua", "settings.appearance.language": "Lingua",
"settings.appearance.languageDescription": "Seleziona una lingua per la tua dashboard" "settings.appearance.languageDescription": "Seleziona una lingua per la tua dashboard"
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -21,6 +21,7 @@ import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification"; import { notificationRouter } from "./routers/notification";
import { portRouter } from "./routers/port"; import { portRouter } from "./routers/port";
import { postgresRouter } from "./routers/postgres"; import { postgresRouter } from "./routers/postgres";
import { previewDeploymentRouter } from "./routers/preview-deployment";
import { projectRouter } from "./routers/project"; import { projectRouter } from "./routers/project";
import { redirectsRouter } from "./routers/redirects"; import { redirectsRouter } from "./routers/redirects";
import { redisRouter } from "./routers/redis"; import { redisRouter } from "./routers/redis";
@@ -30,8 +31,8 @@ import { serverRouter } from "./routers/server";
import { settingsRouter } from "./routers/settings"; import { settingsRouter } from "./routers/settings";
import { sshRouter } from "./routers/ssh-key"; import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe"; import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { userRouter } from "./routers/user"; import { userRouter } from "./routers/user";
import { previewDeploymentRouter } from "./routers/preview-deployment";
/** /**
* This is the primary router for your server. * This is the primary router for your server.
@@ -73,6 +74,7 @@ export const appRouter = createTRPCRouter({
github: githubRouter, github: githubRouter,
server: serverRouter, server: serverRouter,
stripe: stripeRouter, stripe: stripeRouter,
swarm: swarmRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -188,9 +188,9 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const currentAuth = await findAuthByEmail(ctx.user.email); const currentAuth = await findAuthByEmail(ctx.user.email);
if (input.password) { if (input.currentPassword || input.password) {
const correctPassword = bcrypt.compareSync( const correctPassword = bcrypt.compareSync(
input.password, input.currentPassword || "",
currentAuth?.password || "", currentAuth?.password || "",
); );
if (!correctPassword) { if (!correctPassword) {
@@ -268,7 +268,9 @@ export const authRouter = createTRPCRouter({
return auth; return auth;
}), }),
verifyToken: protectedProcedure.mutation(async () => {
return true;
}),
one: adminProcedure.input(apiFindOneAuth).query(async ({ input }) => { one: adminProcedure.input(apiFindOneAuth).query(async ({ input }) => {
const auth = await findAuthById(input.id); const auth = await findAuthById(input.id);
return auth; return auth;
@@ -361,7 +363,7 @@ export const authRouter = createTRPCRouter({
<a href="${WEBSITE_URL}/reset-password?token=${token}"> <a href="${WEBSITE_URL}/reset-password?token=${token}">
Reset Password Reset Password
</a> </a>
`, `,
); );
}), }),
@@ -503,7 +505,7 @@ export const sendDiscordNotificationWelcome = async (newAdmin: Auth) => {
webhookUrl: process.env.DISCORD_WEBHOOK_URL || "", webhookUrl: process.env.DISCORD_WEBHOOK_URL || "",
}, },
{ {
title: " New User Registered", title: "New User Registered",
color: 0x00ff00, color: 0x00ff00,
fields: [ fields: [
{ {

View File

@@ -3,6 +3,7 @@ import { db } from "@/server/db";
import { import {
apiCreateCompose, apiCreateCompose,
apiCreateComposeByTemplate, apiCreateComposeByTemplate,
apiDeleteCompose,
apiFetchServices, apiFetchServices,
apiFindCompose, apiFindCompose,
apiRandomizeCompose, apiRandomizeCompose,
@@ -117,7 +118,7 @@ export const composeRouter = createTRPCRouter({
return updateCompose(input.composeId, input); return updateCompose(input.composeId, input);
}), }),
delete: protectedProcedure delete: protectedProcedure
.input(apiFindCompose) .input(apiDeleteCompose)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
if (ctx.user.rol === "user") { if (ctx.user.rol === "user") {
await checkServiceAccess(ctx.user.authId, input.composeId, "delete"); await checkServiceAccess(ctx.user.authId, input.composeId, "delete");
@@ -138,7 +139,7 @@ export const composeRouter = createTRPCRouter({
.returning(); .returning();
const cleanupOperations = [ const cleanupOperations = [
async () => await removeCompose(composeResult), async () => await removeCompose(composeResult, input.deleteVolumes),
async () => await removeDeploymentsByComposeId(composeResult), async () => await removeDeploymentsByComposeId(composeResult),
async () => await removeComposeDirectory(composeResult.appName), async () => await removeComposeDirectory(composeResult.appName),
]; ];

View File

@@ -4,6 +4,8 @@ import {
getContainers, getContainers,
getContainersByAppLabel, getContainersByAppLabel,
getContainersByAppNameMatch, getContainersByAppNameMatch,
getServiceContainersByAppName,
getStackContainersByAppName,
} from "@dokploy/server"; } from "@dokploy/server";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
@@ -68,4 +70,26 @@ export const dockerRouter = createTRPCRouter({
.query(async ({ input }) => { .query(async ({ input }) => {
return await getContainersByAppLabel(input.appName, input.serverId); return await getContainersByAppLabel(input.appName, input.serverId);
}), }),
getStackContainersByAppName: protectedProcedure
.input(
z.object({
appName: z.string().min(1),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
return await getStackContainersByAppName(input.appName, input.serverId);
}),
getServiceContainersByAppName: protectedProcedure
.input(
z.object({
appName: z.string().min(1),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
return await getServiceContainersByAppName(input.appName, input.serverId);
}),
}); });

View File

@@ -187,11 +187,15 @@ export const notificationRouter = createTRPCRouter({
.input(apiTestDiscordConnection) .input(apiTestDiscordConnection)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const decorate = (decoration: string, text: string) =>
`${input.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(input, { await sendDiscordNotification(input, {
title: "> `🤚` - Test Notification", title: decorate(">", "`🤚` - Test Notification"),
description: "> Hi, From Dokploy 👋", description: decorate(">", "Hi, From Dokploy 👋"),
color: 0xf3f7f4, color: 0xf3f7f4,
}); });
return true; return true;
} catch (error) { } catch (error) {
throw new TRPCError({ throw new TRPCError({

View File

@@ -12,6 +12,7 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
import { removeJob, schedule } from "@/server/utils/backup"; import { removeJob, schedule } from "@/server/utils/backup";
import { import {
DEFAULT_UPDATE_DATA,
IS_CLOUD, IS_CLOUD,
canAccessToTraefikFiles, canAccessToTraefikFiles,
cleanStoppedContainers, cleanStoppedContainers,
@@ -25,6 +26,8 @@ import {
findAdminById, findAdminById,
findServerById, findServerById,
getDokployImage, getDokployImage,
getDokployImageTag,
getUpdateData,
initializeTraefik, initializeTraefik,
logRotationManager, logRotationManager,
parseRawConfig, parseRawConfig,
@@ -267,11 +270,11 @@ export const settingsRouter = createTRPCRouter({
message: "You are not authorized to access this admin", message: "You are not authorized to access this admin",
}); });
} }
await updateAdmin(ctx.user.authId, { const adminUpdated = await updateAdmin(ctx.user.authId, {
enableDockerCleanup: input.enableDockerCleanup, enableDockerCleanup: input.enableDockerCleanup,
}); });
if (admin.enableDockerCleanup) { if (adminUpdated?.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => { scheduleJob("docker-cleanup", "0 0 * * *", async () => {
console.log( console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`, `Docker Cleanup ${new Date().toLocaleString()}] Running...`,
@@ -342,17 +345,20 @@ export const settingsRouter = createTRPCRouter({
writeConfig("middlewares", input.traefikConfig); writeConfig("middlewares", input.traefikConfig);
return true; return true;
}), }),
getUpdateData: adminProcedure.mutation(async () => {
checkAndUpdateImage: adminProcedure.mutation(async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
return true; return DEFAULT_UPDATE_DATA;
} }
return await pullLatestRelease();
return await getUpdateData();
}), }),
updateServer: adminProcedure.mutation(async () => { updateServer: adminProcedure.mutation(async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
return true; return true;
} }
await pullLatestRelease();
await spawnAsync("docker", [ await spawnAsync("docker", [
"service", "service",
"update", "update",
@@ -361,12 +367,16 @@ export const settingsRouter = createTRPCRouter({
getDokployImage(), getDokployImage(),
"dokploy", "dokploy",
]); ]);
return true; return true;
}), }),
getDokployVersion: adminProcedure.query(() => { getDokployVersion: adminProcedure.query(() => {
return packageInfo.version; return packageInfo.version;
}), }),
getReleaseTag: adminProcedure.query(() => {
return getDokployImageTag();
}),
readDirectories: protectedProcedure readDirectories: protectedProcedure
.input(apiServerSchema) .input(apiServerSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -706,6 +716,83 @@ export const settingsRouter = createTRPCRouter({
throw new Error("Failed to check GPU status"); throw new Error("Failed to check GPU status");
} }
}), }),
updateTraefikPorts: adminProcedure
.input(
z.object({
serverId: z.string().optional(),
additionalPorts: z.array(
z.object({
targetPort: z.number(),
publishedPort: z.number(),
publishMode: z.enum(["ingress", "host"]).default("host"),
}),
),
}),
)
.mutation(async ({ input }) => {
try {
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Please set a serverId to update Traefik ports",
});
}
await initializeTraefik({
serverId: input.serverId,
additionalPorts: input.additionalPorts,
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error to update Traefik ports",
cause: error,
});
}
}),
getTraefikPorts: adminProcedure
.input(apiServerSchema)
.query(async ({ input }) => {
const command = `docker service inspect --format='{{json .Endpoint.Ports}}' dokploy-traefik`;
try {
let stdout = "";
if (input?.serverId) {
const result = await execAsyncRemote(input.serverId, command);
stdout = result.stdout;
} else if (!IS_CLOUD) {
const result = await execAsync(command);
stdout = result.stdout;
}
const ports: {
Protocol: string;
TargetPort: number;
PublishedPort: number;
PublishMode: string;
}[] = JSON.parse(stdout.trim());
// Filter out the default ports (80, 443, and optionally 8080)
const additionalPorts = ports
.filter((port) => ![80, 443, 8080].includes(port.PublishedPort))
.map((port) => ({
targetPort: port.TargetPort,
publishedPort: port.PublishedPort,
publishMode: port.PublishMode.toLowerCase() as "host" | "ingress",
}));
return additionalPorts;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to get Traefik ports",
cause: error,
});
}
}),
}); });
// { // {
// "Parallelism": 1, // "Parallelism": 1,

View File

@@ -0,0 +1,44 @@
import {
getApplicationInfo,
getNodeApplications,
getNodeInfo,
getSwarmNodes,
} from "@dokploy/server";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const swarmRouter = createTRPCRouter({
getNodes: protectedProcedure
.input(
z.object({
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
return await getSwarmNodes(input.serverId);
}),
getNodeInfo: protectedProcedure
.input(z.object({ nodeId: z.string(), serverId: z.string().optional() }))
.query(async ({ input }) => {
return await getNodeInfo(input.nodeId, input.serverId);
}),
getNodeApps: protectedProcedure
.input(
z.object({
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
return getNodeApplications(input.serverId);
}),
getAppInfos: protectedProcedure
.input(
z.object({
appName: z.string(),
serverId: z.string().optional(),
}),
)
.query(async ({ input }) => {
return await getApplicationInfo(input.appName, input.serverId);
}),
});

View File

@@ -34,6 +34,7 @@ export const setupDockerContainerLogsWebSocketServer = (
const search = url.searchParams.get("search"); const search = url.searchParams.get("search");
const since = url.searchParams.get("since"); const since = url.searchParams.get("since");
const serverId = url.searchParams.get("serverId"); const serverId = url.searchParams.get("serverId");
const runType = url.searchParams.get("runType");
const { user, session } = await validateWebSocketRequest(req); const { user, session } = await validateWebSocketRequest(req);
if (!containerId) { if (!containerId) {
@@ -53,7 +54,7 @@ export const setupDockerContainerLogsWebSocketServer = (
const client = new Client(); const client = new Client();
client client
.once("ready", () => { .once("ready", () => {
const baseCommand = `docker container logs --timestamps --tail ${tail} ${ const baseCommand = `docker ${runType === "swarm" ? "service" : "container"} logs --timestamps --tail ${tail} ${
since === "all" ? "" : `--since ${since}` since === "all" ? "" : `--since ${since}`
} --follow ${containerId}`; } --follow ${containerId}`;
const escapedSearch = search ? search.replace(/'/g, "'\\''") : ""; const escapedSearch = search ? search.replace(/'/g, "'\\''") : "";
@@ -97,7 +98,7 @@ export const setupDockerContainerLogsWebSocketServer = (
}); });
} else { } else {
const shell = getShell(); const shell = getShell();
const baseCommand = `docker container logs --timestamps --tail ${tail} ${ const baseCommand = `docker ${runType === "swarm" ? "service" : "container"} logs --timestamps --tail ${tail} ${
since === "all" ? "" : `--since ${since}` since === "all" ? "" : `--since ${since}`
} --follow ${containerId}`; } --follow ${containerId}`;
const command = search const command = search

View File

@@ -87,7 +87,7 @@ const config = {
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [require("tailwindcss-animate"), require("fancy-ansi/plugin")],
} satisfies Config; } satisfies Config;
export default config; export default config;

View File

@@ -0,0 +1,12 @@
---
services:
onedev:
image: 1dev/server:11.6.6
restart: always
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
- "onedev-data:/opt/onedev"
volumes:
onedev-data:

View File

@@ -0,0 +1,22 @@
import {
type DomainSchema,
type Schema,
type Template,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const randomDomain = generateRandomDomain(schema);
const domains: DomainSchema[] = [
{
host: randomDomain,
port: 6610,
serviceName: "onedev",
},
];
return {
domains,
};
}

View File

@@ -26,7 +26,7 @@ services:
hard: 262144 hard: 262144
plausible: plausible:
image: ghcr.io/plausible/community-edition:v2.1.0 image: ghcr.io/plausible/community-edition:v2.1.4
restart: always restart: always
command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run" command: sh -c "sleep 10 && /entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run"
depends_on: depends_on:

View File

@@ -34,7 +34,7 @@ export const templates: TemplateData[] = [
{ {
id: "plausible", id: "plausible",
name: "Plausible", name: "Plausible",
version: "v2.1.0", version: "v2.1.4",
description: description:
"Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.", "Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.",
logo: "plausible.svg", logo: "plausible.svg",
@@ -1136,4 +1136,19 @@ export const templates: TemplateData[] = [
tags: ["search", "analytics"], tags: ["search", "analytics"],
load: () => import("./elastic-search/index").then((m) => m.generate), load: () => import("./elastic-search/index").then((m) => m.generate),
}, },
{
id: "onedev",
name: "OneDev",
version: "11.6.6",
description:
"Git server with CI/CD, kanban, and packages. Seamless integration. Unparalleled experience.",
logo: "onedev.png",
links: {
github: "https://github.com/theonedev/onedev/",
website: "https://onedev.io/",
docs: "https://docs.onedev.io/",
},
tags: ["self-hosted", "development"],
load: () => import("./onedev/index").then((m) => m.generate),
},
]; ];

View File

@@ -1,44 +1,44 @@
import { import {
generateHash, type DomainSchema,
generateRandomDomain, type Schema,
generateBase64, type Template,
type Template, generateBase64,
type Schema, generateHash,
type DomainSchema, generateRandomDomain,
} from "../utils"; } from "../utils";
export function generate(schema: Schema): Template { export function generate(schema: Schema): Template {
const mainDomain = generateRandomDomain(schema); const mainDomain = generateRandomDomain(schema);
const secretBase = generateBase64(64); const secretBase = generateBase64(64);
const domains: DomainSchema[] = [ const domains: DomainSchema[] = [
{ {
host: mainDomain, host: mainDomain,
port: 3000, port: 3000,
serviceName: "unsend", serviceName: "unsend",
}, },
]; ];
const envs = [ const envs = [
"REDIS_URL=redis://unsend-redis-prod:6379", "REDIS_URL=redis://unsend-redis-prod:6379",
"POSTGRES_USER=postgres", "POSTGRES_USER=postgres",
"POSTGRES_PASSWORD=postgres", "POSTGRES_PASSWORD=postgres",
"POSTGRES_DB=unsend", "POSTGRES_DB=unsend",
"DATABASE_URL=postgresql://postgres:postgres@unsend-db-prod:5432/unsend", "DATABASE_URL=postgresql://postgres:postgres@unsend-db-prod:5432/unsend",
"NEXTAUTH_URL=http://localhost:3000", "NEXTAUTH_URL=http://localhost:3000",
`NEXTAUTH_SECRET=${secretBase}`, `NEXTAUTH_SECRET=${secretBase}`,
"GITHUB_ID='Fill'", "GITHUB_ID='Fill'",
"GITHUB_SECRET='Fill'", "GITHUB_SECRET='Fill'",
"AWS_DEFAULT_REGION=us-east-1", "AWS_DEFAULT_REGION=us-east-1",
"AWS_SECRET_KEY='Fill'", "AWS_SECRET_KEY='Fill'",
"AWS_ACCESS_KEY='Fill'", "AWS_ACCESS_KEY='Fill'",
"DOCKER_OUTPUT=1", "DOCKER_OUTPUT=1",
"API_RATE_LIMIT=1", "API_RATE_LIMIT=1",
"DISCORD_WEBHOOK_URL=", "DISCORD_WEBHOOK_URL=",
]; ];
return { return {
envs, envs,
domains, domains,
}; };
} }

View File

@@ -5,34 +5,22 @@ export const IS_CLOUD = process.env.IS_CLOUD === "true";
export const docker = new Docker(); export const docker = new Docker();
export const paths = (isServer = false) => { export const paths = (isServer = false) => {
if (isServer) {
const BASE_PATH = "/etc/dokploy";
return {
BASE_PATH,
MAIN_TRAEFIK_PATH: `${BASE_PATH}/traefik`,
DYNAMIC_TRAEFIK_PATH: `${BASE_PATH}/traefik/dynamic`,
LOGS_PATH: `${BASE_PATH}/logs`,
APPLICATIONS_PATH: `${BASE_PATH}/applications`,
COMPOSE_PATH: `${BASE_PATH}/compose`,
SSH_PATH: `${BASE_PATH}/ssh`,
CERTIFICATES_PATH: `${BASE_PATH}/certificates`,
MONITORING_PATH: `${BASE_PATH}/monitoring`,
REGISTRY_PATH: `${BASE_PATH}/registry`,
};
}
const BASE_PATH = const BASE_PATH =
process.env.NODE_ENV === "production" isServer || process.env.NODE_ENV === "production"
? "/etc/dokploy" ? "/etc/dokploy"
: path.join(process.cwd(), ".docker"); : path.join(process.cwd(), ".docker");
const MAIN_TRAEFIK_PATH = `${BASE_PATH}/traefik`;
const DYNAMIC_TRAEFIK_PATH = `${MAIN_TRAEFIK_PATH}/dynamic`;
return { return {
BASE_PATH, BASE_PATH,
MAIN_TRAEFIK_PATH: `${BASE_PATH}/traefik`, MAIN_TRAEFIK_PATH,
DYNAMIC_TRAEFIK_PATH: `${BASE_PATH}/traefik/dynamic`, DYNAMIC_TRAEFIK_PATH,
LOGS_PATH: `${BASE_PATH}/logs`, LOGS_PATH: `${BASE_PATH}/logs`,
APPLICATIONS_PATH: `${BASE_PATH}/applications`, APPLICATIONS_PATH: `${BASE_PATH}/applications`,
COMPOSE_PATH: `${BASE_PATH}/compose`, COMPOSE_PATH: `${BASE_PATH}/compose`,
SSH_PATH: `${BASE_PATH}/ssh`, SSH_PATH: `${BASE_PATH}/ssh`,
CERTIFICATES_PATH: `${BASE_PATH}/certificates`, CERTIFICATES_PATH: `${DYNAMIC_TRAEFIK_PATH}/certificates`,
MONITORING_PATH: `${BASE_PATH}/monitoring`, MONITORING_PATH: `${BASE_PATH}/monitoring`,
REGISTRY_PATH: `${BASE_PATH}/registry`, REGISTRY_PATH: `${BASE_PATH}/registry`,
}; };

View File

@@ -17,6 +17,7 @@ import { github } from "./github";
import { gitlab } from "./gitlab"; import { gitlab } from "./gitlab";
import { mounts } from "./mount"; import { mounts } from "./mount";
import { ports } from "./port"; import { ports } from "./port";
import { previewDeployments } from "./preview-deployments";
import { projects } from "./project"; import { projects } from "./project";
import { redirects } from "./redirects"; import { redirects } from "./redirects";
import { registry } from "./registry"; import { registry } from "./registry";
@@ -25,7 +26,6 @@ import { server } from "./server";
import { applicationStatus, certificateType } from "./shared"; import { applicationStatus, certificateType } from "./shared";
import { sshKeys } from "./ssh-key"; import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils"; import { generateAppName } from "./utils";
import { previewDeployments } from "./preview-deployments";
export const sourceType = pgEnum("sourceType", [ export const sourceType = pgEnum("sourceType", [
"docker", "docker",

View File

@@ -155,6 +155,11 @@ export const apiFindCompose = z.object({
composeId: z.string().min(1), composeId: z.string().min(1),
}); });
export const apiDeleteCompose = z.object({
composeId: z.string().min(1),
deleteVolumes: z.boolean(),
});
export const apiFetchServices = z.object({ export const apiFetchServices = z.object({
composeId: z.string().min(1), composeId: z.string().min(1),
type: z.enum(["fetch", "cache"]).optional().default("cache"), type: z.enum(["fetch", "cache"]).optional().default("cache"),

View File

@@ -11,8 +11,8 @@ import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { applications } from "./application"; import { applications } from "./application";
import { compose } from "./compose"; import { compose } from "./compose";
import { server } from "./server";
import { previewDeployments } from "./preview-deployments"; import { previewDeployments } from "./preview-deployments";
import { server } from "./server";
export const deploymentStatus = pgEnum("deploymentStatus", [ export const deploymentStatus = pgEnum("deploymentStatus", [
"running", "running",

View File

@@ -14,8 +14,8 @@ import { z } from "zod";
import { domain } from "../validations/domain"; import { domain } from "../validations/domain";
import { applications } from "./application"; import { applications } from "./application";
import { compose } from "./compose"; import { compose } from "./compose";
import { certificateType } from "./shared";
import { previewDeployments } from "./preview-deployments"; import { previewDeployments } from "./preview-deployments";
import { certificateType } from "./shared";
export const domainType = pgEnum("domainType", [ export const domainType = pgEnum("domainType", [
"compose", "compose",

View File

@@ -29,4 +29,4 @@ export * from "./github";
export * from "./gitlab"; export * from "./gitlab";
export * from "./server"; export * from "./server";
export * from "./utils"; export * from "./utils";
export * from "./preview-deployments"; export * from "./preview-deployments";

View File

@@ -68,6 +68,7 @@ export const discord = pgTable("discord", {
.primaryKey() .primaryKey()
.$defaultFn(() => nanoid()), .$defaultFn(() => nanoid()),
webhookUrl: text("webhookUrl").notNull(), webhookUrl: text("webhookUrl").notNull(),
decoration: boolean("decoration"),
}); });
export const email = pgTable("email", { export const email = pgTable("email", {
@@ -171,6 +172,7 @@ export const apiCreateDiscord = notificationsSchema
}) })
.extend({ .extend({
webhookUrl: z.string().min(1), webhookUrl: z.string().min(1),
decoration: z.boolean(),
}) })
.required(); .required();
@@ -180,9 +182,13 @@ export const apiUpdateDiscord = apiCreateDiscord.partial().extend({
adminId: z.string().optional(), adminId: z.string().optional(),
}); });
export const apiTestDiscordConnection = apiCreateDiscord.pick({ export const apiTestDiscordConnection = apiCreateDiscord
webhookUrl: true, .pick({
}); webhookUrl: true,
})
.extend({
decoration: z.boolean().optional(),
});
export const apiCreateEmail = notificationsSchema export const apiCreateEmail = notificationsSchema
.pick({ .pick({

View File

@@ -1,13 +1,13 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core"; import { pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { applications } from "./application";
import { domains } from "./domain";
import { deployments } from "./deployment";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { generateAppName } from "./utils"; import { applications } from "./application";
import { deployments } from "./deployment";
import { domains } from "./domain";
import { applicationStatus } from "./shared"; import { applicationStatus } from "./shared";
import { generateAppName } from "./utils";
export const previewDeployments = pgTable("preview_deployments", { export const previewDeployments = pgTable("preview_deployments", {
previewDeploymentId: text("previewDeploymentId") previewDeploymentId: text("previewDeploymentId")

View File

@@ -1,3 +1,4 @@
import { generatePassword } from "@dokploy/server/templates/utils";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
@@ -13,3 +14,17 @@ export const generateAppName = (type: string) => {
const nanoidPart = customNanoid(); const nanoidPart = customNanoid();
return `${type}-${randomFakerElement}-${nanoidPart}`; return `${type}-${randomFakerElement}-${nanoidPart}`;
}; };
export const cleanAppName = (appName?: string) => {
if (!appName) {
return appName?.toLowerCase();
}
return appName.trim().replace(/ /g, "-").toLowerCase();
};
export const buildAppName = (type: string, baseAppName?: string) => {
if (baseAppName) {
return `${cleanAppName(baseAppName)}-${generatePassword(6)}`;
}
return generateAppName(type);
};

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