Merge branch 'canary' into feature/delete-docker-volumes

This commit is contained in:
djknaeckebrot
2024-12-23 08:13:12 +01:00
76 changed files with 3638 additions and 861 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

@@ -111,7 +111,7 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
<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) => ( filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine <TerminalLine

View File

@@ -26,7 +26,7 @@ export const ShowPreviewBuilds = ({ deployments, serverId }: Props) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline">View Builds</Button> <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,212 +1,237 @@
import { DateTooltip } from "@/components/shared/date-tooltip"; import React from "react";
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 { Separator } from "@/components/ui/separator";
import { import {
Card, Clock,
CardContent, GitBranch,
CardDescription, GitPullRequest,
CardHeader, Pencil,
CardTitle, RocketIcon,
} from "@/components/ui/card"; } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { Pencil, RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { toast } from "sonner";
import { ShowDeployment } from "../deployments/show-deployment";
import Link from "next/link"; import 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 { DialogAction } from "@/components/shared/dialog-action";
import { AddPreviewDomain } from "./add-preview-domain"; import { api } from "@/utils/api";
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 { DateTooltip } from "@/components/shared/date-tooltip";
import { toast } from "sonner";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { AddPreviewDomain } from "./add-preview-domain";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
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 } =
api.previewDeployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
},
);
// const [url, setUrl] = React.useState("");
// useEffect(() => {
// setUrl(document.location.origin);
// }, []);
return ( const { data: previewDeployments, refetch: refetchPreviewDeployments } =
<Card className="bg-background"> api.previewDeployment.all.useQuery(
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2"> { applicationId },
<div className="flex flex-col gap-2"> {
<CardTitle className="text-xl">Preview Deployments</CardTitle> enabled: !!applicationId,
<CardDescription>See all the preview deployments</CardDescription> }
</div> );
{data?.isPreviewDeploymentsActive && (
<ShowPreviewSettings applicationId={applicationId} />
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{data?.isPreviewDeploymentsActive ? (
<>
<div className="flex flex-col gap-2 text-sm">
<span>
Preview deployments are a way to test your application before it
is deployed to production. It will create a new deployment for
each pull request you create.
</span>
</div>
{data?.previewDeployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No preview deployments found
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{previewDeployments?.map((previewDeployment) => {
const { deployments, domain } = previewDeployment;
return ( const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
<div deletePreviewDeployment({
key={previewDeployment?.previewDeploymentId} previewDeploymentId: previewDeploymentId,
className="flex flex-col justify-between rounded-lg border p-4 gap-2" })
> .then(() => {
<div className="flex justify-between gap-2 max-sm:flex-wrap"> refetchPreviewDeployments();
<div className="flex flex-col gap-2"> toast.success("Preview deployment deleted");
{deployments?.length === 0 ? ( })
<div> .catch((error) => {
<span className="text-sm text-muted-foreground"> toast.error(error.message);
No deployments found });
</span> };
</div>
) : (
<div className="flex items-center gap-2">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{previewDeployment?.pullRequestTitle}
</span>
<StatusTooltip
status={previewDeployment.previewStatus}
className="size-2.5"
/>
</div>
)}
<div className="flex flex-col gap-1">
{previewDeployment?.pullRequestTitle && (
<div className="flex items-center gap-2">
<span className="break-all text-sm text-muted-foreground w-fit">
Title: {previewDeployment?.pullRequestTitle}
</span>
</div>
)}
{previewDeployment?.pullRequestURL && ( return (
<div className="flex items-center gap-2"> <Card className="bg-background">
<GithubIcon /> <CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<Link <div className="flex flex-col gap-2">
target="_blank" <CardTitle className="text-xl">Preview Deployments</CardTitle>
href={previewDeployment?.pullRequestURL} <CardDescription>See all the preview deployments</CardDescription>
className="break-all text-sm text-muted-foreground w-fit hover:underline hover:text-foreground" </div>
> {data?.isPreviewDeploymentsActive && (
Pull Request URL <ShowPreviewSettings applicationId={applicationId} />
</Link> )}
</div> </CardHeader>
)} <CardContent className="flex flex-col gap-4">
</div> {data?.isPreviewDeploymentsActive ? (
<div className="flex flex-col "> <>
<span>Domain </span> <div className="flex flex-col gap-2 text-sm">
<div className="flex flex-row items-center gap-4"> <span>
<Link Preview deployments are a way to test your application before it
target="_blank" is deployed to production. It will create a new deployment for
href={`http://${domain?.host}`} each pull request you create.
className="text-sm text-muted-foreground w-fit hover:underline hover:text-foreground" </span>
> </div>
{domain?.host} {!previewDeployments?.length ? (
</Link> <div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<AddPreviewDomain <RocketIcon className="size-8 text-muted-foreground" />
previewDeploymentId={ <span className="text-base text-muted-foreground">
previewDeployment.previewDeploymentId No preview deployments found
} </span>
domainId={domain?.domainId} </div>
> ) : (
<Button variant="outline" size="sm"> <div className="flex flex-col gap-4">
<Pencil className="size-4 text-muted-foreground" /> {previewDeployments.map((previewDeployment) => (
</Button> <div
</AddPreviewDomain> key={previewDeployment.previewDeploymentId}
</div> className="w-full border rounded-xl"
</div> >
</div> <div className="md:p-6 p-2 md:pb-3 flex flex-row items-center justify-between">
<span className="text-lg font-bold">
{previewDeployment.pullRequestTitle}
</span>
<Badge
variant="outline"
className="text-sm font-medium gap-x-2"
>
<StatusTooltip
status={previewDeployment.previewStatus}
className="size-2.5"
/>
{previewDeployment.previewStatus
?.replace("running", "Running")
.replace("done", "Done")
.replace("error", "Error")
.replace("idle", "Idle") || "Idle"}
</Badge>
</div>
<div className="flex flex-col sm:items-end gap-2 max-sm:w-full"> <div className="md:p-6 p-2 md:pt-0 space-y-4">
{previewDeployment?.createdAt && ( <div className="flex sm:flex-row flex-col items-center gap-2">
<div className="text-sm capitalize text-muted-foreground"> <Link
<DateTooltip href={`http://${previewDeployment.domain?.host}`}
date={previewDeployment?.createdAt} target="_blank"
/> className="text-sm text-blue-500/95 hover:underline gap-2 flex w-full sm:flex-row flex-col items-center justify-between rounded-lg border p-2"
</div> >
)} {previewDeployment.domain?.host}
<ShowPreviewBuilds </Link>
deployments={previewDeployment?.deployments || []}
serverId={data?.serverId || ""}
/>
<ShowModalLogs <AddPreviewDomain
appName={previewDeployment.appName} previewDeploymentId={
serverId={data?.serverId || ""} previewDeployment.previewDeploymentId
> }
<Button variant="outline">View Logs</Button> domainId={previewDeployment.domain?.domainId}
</ShowModalLogs> >
<Button
className="sm:w-auto w-full"
size="sm"
variant="outline"
>
<Pencil className="size-4" />
Edit
</Button>
</AddPreviewDomain>
</div>
<DialogAction <div className="flex sm:flex-row text-sm flex-col items-center justify-between">
title="Delete Preview" <div className="flex items-center space-x-2">
description="Are you sure you want to delete this preview?" <GitBranch className="size-5 text-gray-400" />
onClick={() => { <span>Branch:</span>
deletePreviewDeployment({ <Badge className="p-2" variant="blank">
previewDeploymentId: {previewDeployment.branch}
previewDeployment.previewDeploymentId, </Badge>
}) </div>
.then(() => { <div className="flex items-center space-x-2">
refetchPreviewDeployments(); <Clock className="size-5 text-gray-400" />
toast.success("Preview deployment deleted"); <span>Deployed:</span>
}) <Badge className="p-2" variant="blank">
.catch((error) => { <DateTooltip date={previewDeployment.createdAt} />
toast.error(error.message); </Badge>
}); </div>
}} </div>
>
<Button variant="destructive" isLoading={isLoading}> <Separator />
Delete Preview
</Button> <div className="rounded-lg bg-muted p-4">
</DialogAction> <h3 className="mb-2 text-sm font-medium">
</div> Pull Request
</div> </h3>
</div> <div className="flex items-center space-x-2 text-sm text-muted-foreground">
); <GitPullRequest className="size-5 text-gray-400" />
})} <Link
</div> className="hover:text-blue-500/95 hover:underline"
)} target="_blank"
</> href={previewDeployment.pullRequestURL}
) : ( >
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10"> {previewDeployment.pullRequestTitle}
<RocketIcon className="size-8 text-muted-foreground" /> </Link>
<span className="text-base text-muted-foreground"> </div>
Preview deployments are disabled for this application, please </div>
enable it </div>
</span>
<ShowPreviewSettings applicationId={applicationId} /> <div className="justify-center flex-wrap md:p-6 p-2 md:pt-0">
</div> <div className="flex flex-wrap justify-end gap-2">
)} <ShowModalLogs
</CardContent> appName={previewDeployment.appName}
</Card> serverId={data?.serverId || ""}
); >
<Button
className="sm:w-auto w-full"
variant="outline"
size="sm"
>
View Logs
</Button>
</ShowModalLogs>
<ShowPreviewBuilds
deployments={previewDeployment.deployments || []}
serverId={data?.serverId || ""}
/>
<DialogAction
title="Delete Preview"
description="Are you sure you want to delete this preview?"
onClick={() =>
handleDeletePreviewDeployment(
previewDeployment.previewDeploymentId
)
}
>
<Button
className="sm:w-auto w-full"
variant="destructive"
isLoading={isLoading}
size="sm"
>
Delete Preview
</Button>
</DialogAction>
</div>
</div>
</div>
))}
</div>
)}
</>
) : (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
Preview deployments are disabled for this application, please
enable it
</span>
<ShowPreviewSettings applicationId={applicationId} />
</div>
)}
</CardContent>
</Card>
);
}; };

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

@@ -117,7 +117,7 @@ export const ShowDeploymentCompose = ({
<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"
> >

View File

@@ -1,309 +1,291 @@
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;
} }
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> = ({ containerId, serverId }) => {
const { data } = api.docker.getConfig.useQuery( const { data } = api.docker.getConfig.useQuery(
{ {
containerId, containerId,
serverId: serverId ?? undefined, serverId: serverId ?? undefined,
}, },
{ {
enabled: !!containerId, 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(""); });
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

@@ -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

@@ -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

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

View File

@@ -26,7 +26,7 @@ import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Mail, PenBoxIcon } from "lucide-react"; import { Mail, Pen } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { FieldErrors, useFieldArray, useForm } from "react-hook-form"; import { FieldErrors, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -218,8 +218,10 @@ export const UpdateNotification = ({ notificationId }: Props) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild> <DialogTrigger className="" asChild>
<Button variant="ghost"> <Button variant="ghost"
<PenBoxIcon className="size-4 text-muted-foreground" /> size="icon"
className="h-9 w-9 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">

View File

@@ -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

@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils";
import { useTranslation } from "next-i18next"; import { useTranslation } from "next-i18next";
import { EditTraefikEnv } from "../../web-server/edit-traefik-env"; import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
import { ShowModalLogs } from "../../web-server/show-modal-logs"; import { ShowModalLogs } from "../../web-server/show-modal-logs";
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
interface Props { interface Props {
serverId?: string; serverId?: string;
@@ -128,6 +129,14 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
<span>Enter the terminal</span> <span>Enter the terminal</span>
</DropdownMenuItem> </DropdownMenuItem>
</DockerTerminalModal> */} </DockerTerminalModal> */}
<ManageTraefikPorts serverId={serverId}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="cursor-pointer"
>
<span>{t("settings.server.webServer.traefik.managePorts")}</span>
</DropdownMenuItem>
</ManageTraefikPorts>
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -33,6 +33,7 @@ import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { UpdateServer } from "./update-server"; import { UpdateServer } from "./update-server";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription"; import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
export const ShowServers = () => { export const ShowServers = () => {
const router = useRouter(); const router = useRouter();
@@ -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

@@ -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

@@ -0,0 +1,230 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";
import { useTranslation } from "next-i18next";
import type React from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
/**
* Props for the ManageTraefikPorts component
* @interface Props
* @property {React.ReactNode} children - The trigger element that opens the ports management modal
* @property {string} [serverId] - Optional ID of the server whose ports are being managed
*/
interface Props {
children: React.ReactNode;
serverId?: string;
}
/**
* Represents a port mapping configuration for Traefik
* @interface AdditionalPort
* @property {number} targetPort - The internal port that the service is listening on
* @property {number} publishedPort - The external port that will be exposed
* @property {"ingress" | "host"} publishMode - The Docker Swarm publish mode:
* - "host": Publishes the port directly on the host
* - "ingress": Publishes the port through the Swarm routing mesh
*/
interface AdditionalPort {
targetPort: number;
publishedPort: number;
publishMode: "ingress" | "host";
}
/**
* ManageTraefikPorts is a component that provides a modal interface for managing
* additional port mappings for Traefik in a Docker Swarm environment.
*
* Features:
* - Add, remove, and edit port mappings
* - Configure target port, published port, and publish mode for each mapping
* - Persist port configurations through API calls
*
* @component
* @example
* ```tsx
* <ManageTraefikPorts serverId="server-123">
* <Button>Manage Ports</Button>
* </ManageTraefikPorts>
* ```
*/
export const ManageTraefikPorts = ({ children, serverId }: Props) => {
const { t } = useTranslation("settings");
const [open, setOpen] = useState(false);
const [additionalPorts, setAdditionalPorts] = useState<AdditionalPort[]>([]);
const { data: currentPorts, refetch: refetchPorts } =
api.settings.getTraefikPorts.useQuery({
serverId,
});
const { mutateAsync: updatePorts, isLoading } =
api.settings.updateTraefikPorts.useMutation({
onSuccess: () => {
refetchPorts();
},
});
useEffect(() => {
if (currentPorts) {
setAdditionalPorts(currentPorts);
}
}, [currentPorts]);
const handleAddPort = () => {
setAdditionalPorts([
...additionalPorts,
{ targetPort: 0, publishedPort: 0, publishMode: "host" },
]);
};
const handleUpdatePorts = async () => {
try {
await updatePorts({
serverId,
additionalPorts,
});
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
setOpen(false);
} catch (error) {
toast.error(t("settings.server.webServer.traefik.portsUpdateError"));
}
};
return (
<>
<div onClick={() => setOpen(true)}>{children}</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("settings.server.webServer.traefik.managePorts")}
</DialogTitle>
<DialogDescription>
{t("settings.server.webServer.traefik.managePortsDescription")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{additionalPorts.map((port, index) => (
<div
key={index}
className="grid grid-cols-[120px_120px_minmax(120px,1fr)_80px] gap-4 items-end"
>
<div className="space-y-2">
<Label htmlFor={`target-port-${index}`}>
{t("settings.server.webServer.traefik.targetPort")}
</Label>
<input
id={`target-port-${index}`}
type="number"
value={port.targetPort}
onChange={(e) => {
const newPorts = [...additionalPorts];
if (newPorts[index]) {
newPorts[index].targetPort = Number.parseInt(
e.target.value,
);
}
setAdditionalPorts(newPorts);
}}
className="w-full rounded border p-2"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`published-port-${index}`}>
{t("settings.server.webServer.traefik.publishedPort")}
</Label>
<input
id={`published-port-${index}`}
type="number"
value={port.publishedPort}
onChange={(e) => {
const newPorts = [...additionalPorts];
if (newPorts[index]) {
newPorts[index].publishedPort = Number.parseInt(
e.target.value,
);
}
setAdditionalPorts(newPorts);
}}
className="w-full rounded border p-2"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`publish-mode-${index}`}>
{t("settings.server.webServer.traefik.publishMode")}
</Label>
<Select
value={port.publishMode}
onValueChange={(value: "ingress" | "host") => {
const newPorts = [...additionalPorts];
if (newPorts[index]) {
newPorts[index].publishMode = value;
}
setAdditionalPorts(newPorts);
}}
>
<SelectTrigger
id={`publish-mode-${index}`}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="host">Host</SelectItem>
<SelectItem value="ingress">Ingress</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Button
onClick={() => {
const newPorts = additionalPorts.filter(
(_, i) => i !== index,
);
setAdditionalPorts(newPorts);
}}
variant="destructive"
size="sm"
>
Remove
</Button>
</div>
</div>
))}
<div className="mt-4 flex justify-between">
<Button onClick={handleAddPort} variant="outline" size="sm">
{t("settings.server.webServer.traefik.addPort")}
</Button>
<Button
onClick={handleUpdatePorts}
size="sm"
disabled={isLoading}
>
Save
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
};

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

@@ -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

@@ -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

@@ -18,6 +18,14 @@
"settings.server.webServer.server.label": "Server", "settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik", "settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Modify Env", "settings.server.webServer.traefik.modifyEnv": "Modify Env",
"settings.server.webServer.traefik.managePorts": "Additional Ports",
"settings.server.webServer.traefik.managePortsDescription": "Add or remove additional ports for Traefik",
"settings.server.webServer.traefik.targetPort": "Target Port",
"settings.server.webServer.traefik.publishedPort": "Published Port",
"settings.server.webServer.traefik.addPort": "Add Port",
"settings.server.webServer.traefik.portsUpdated": "Ports updated successfully",
"settings.server.webServer.traefik.portsUpdateError": "Failed to update ports",
"settings.server.webServer.traefik.publishMode": "Publish Mode",
"settings.server.webServer.storage.label": "Space", "settings.server.webServer.storage.label": "Space",
"settings.server.webServer.storage.cleanUnusedImages": "Clean unused images", "settings.server.webServer.storage.cleanUnusedImages": "Clean unused images",
"settings.server.webServer.storage.cleanUnusedVolumes": "Clean unused volumes", "settings.server.webServer.storage.cleanUnusedVolumes": "Clean unused volumes",

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;

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

@@ -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,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);
};

View File

@@ -3,10 +3,10 @@ import { db } from "@dokploy/server/db";
import { import {
type apiCreateApplication, type apiCreateApplication,
applications, applications,
buildAppName,
cleanAppName,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema";
import { getAdvancedStats } from "@dokploy/server/monitoring/utilts"; import { getAdvancedStats } from "@dokploy/server/monitoring/utilts";
import { generatePassword } from "@dokploy/server/templates/utils";
import { import {
buildApplication, buildApplication,
getBuildCommand, getBuildCommand,
@@ -46,34 +46,31 @@ import {
createDeploymentPreview, createDeploymentPreview,
updateDeploymentStatus, updateDeploymentStatus,
} from "./deployment"; } from "./deployment";
import { validUniqueServerAppName } from "./project"; import { type Domain, getDomainHost } from "./domain";
import {
findPreviewDeploymentById,
updatePreviewDeployment,
} from "./preview-deployment";
import { import {
createPreviewDeploymentComment, createPreviewDeploymentComment,
getIssueComment, getIssueComment,
issueCommentExists, issueCommentExists,
updateIssueComment, updateIssueComment,
} from "./github"; } from "./github";
import { type Domain, getDomainHost } from "./domain"; import {
findPreviewDeploymentById,
updatePreviewDeployment,
} from "./preview-deployment";
import { validUniqueServerAppName } from "./project";
export type Application = typeof applications.$inferSelect; export type Application = typeof applications.$inferSelect;
export const createApplication = async ( export const createApplication = async (
input: typeof apiCreateApplication._type, input: typeof apiCreateApplication._type,
) => { ) => {
input.appName = const appName = buildAppName("app", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("app");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) { const valid = await validUniqueServerAppName(appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Application with this 'AppName' already exists", code: "CONFLICT",
}); message: "Application with this 'AppName' already exists",
} });
} }
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
@@ -81,6 +78,7 @@ export const createApplication = async (
.insert(applications) .insert(applications)
.values({ .values({
...input, ...input,
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -140,10 +138,11 @@ export const updateApplication = async (
applicationId: string, applicationId: string,
applicationData: Partial<Application>, applicationData: Partial<Application>,
) => { ) => {
const { appName, ...rest } = applicationData;
const application = await db const application = await db
.update(applications) .update(applications)
.set({ .set({
...applicationData, ...rest,
}) })
.where(eq(applications.applicationId, applicationId)) .where(eq(applications.applicationId, applicationId))
.returning(); .returning();

View File

@@ -2,7 +2,7 @@ import { join } from "node:path";
import { paths } from "@dokploy/server/constants"; import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { type apiCreateCompose, compose } from "@dokploy/server/db/schema"; import { type apiCreateCompose, compose } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { import {
buildCompose, buildCompose,
@@ -52,17 +52,14 @@ import { validUniqueServerAppName } from "./project";
export type Compose = typeof compose.$inferSelect; export type Compose = typeof compose.$inferSelect;
export const createCompose = async (input: typeof apiCreateCompose._type) => { export const createCompose = async (input: typeof apiCreateCompose._type) => {
input.appName = const appName = buildAppName("compose", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("compose");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) { const valid = await validUniqueServerAppName(appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Service with this 'AppName' already exists", code: "CONFLICT",
}); message: "Service with this 'AppName' already exists",
} });
} }
const newDestination = await db const newDestination = await db
@@ -70,6 +67,7 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => {
.values({ .values({
...input, ...input,
composeFile: "", composeFile: "",
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -87,8 +85,9 @@ export const createCompose = async (input: typeof apiCreateCompose._type) => {
export const createComposeByTemplate = async ( export const createComposeByTemplate = async (
input: typeof compose.$inferInsert, input: typeof compose.$inferInsert,
) => { ) => {
if (input.appName) { const appName = cleanAppName(input.appName);
const valid = await validUniqueServerAppName(input.appName); if (appName) {
const valid = await validUniqueServerAppName(appName);
if (!valid) { if (!valid) {
throw new TRPCError({ throw new TRPCError({
@@ -101,6 +100,7 @@ export const createComposeByTemplate = async (
.insert(compose) .insert(compose)
.values({ .values({
...input, ...input,
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -184,10 +184,11 @@ export const updateCompose = async (
composeId: string, composeId: string,
composeData: Partial<Compose>, composeData: Partial<Compose>,
) => { ) => {
const { appName, ...rest } = composeData;
const composeResult = await db const composeResult = await db
.update(compose) .update(compose)
.set({ .set({
...composeData, ...rest,
}) })
.where(eq(compose.composeId, composeId)) .where(eq(compose.composeId, composeId))
.returning(); .returning();

View File

@@ -23,8 +23,8 @@ import { type Server, findServerById } from "./server";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { import {
findPreviewDeploymentById,
type PreviewDeployment, type PreviewDeployment,
findPreviewDeploymentById,
updatePreviewDeployment, updatePreviewDeployment,
} from "./preview-deployment"; } from "./preview-deployment";

View File

@@ -224,3 +224,124 @@ export const containerRestart = async (containerId: string) => {
return config; return config;
} catch (error) {} } catch (error) {}
}; };
export const getSwarmNodes = async (serverId?: string) => {
try {
let stdout = "";
let stderr = "";
const command = "docker node ls --format '{{json .}}'";
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execAsync(command);
stdout = result.stdout;
stderr = result.stderr;
}
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
const nodes = JSON.parse(stdout);
const nodesArray = stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return nodesArray;
} catch (error) {}
};
export const getNodeInfo = async (nodeId: string, serverId?: string) => {
try {
const command = `docker node inspect ${nodeId} --format '{{json .}}'`;
let stdout = "";
let stderr = "";
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execAsync(command);
stdout = result.stdout;
stderr = result.stderr;
}
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
const nodeInfo = JSON.parse(stdout);
return nodeInfo;
} catch (error) {}
};
export const getNodeApplications = async (serverId?: string) => {
try {
let stdout = "";
let stderr = "";
const command = `docker service ls --format '{{json .}}'`;
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execAsync(command);
stdout = result.stdout;
stderr = result.stderr;
}
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
const appArray = stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return appArray;
} catch (error) {}
};
export const getApplicationInfo = async (
appName: string,
serverId?: string,
) => {
try {
let stdout = "";
let stderr = "";
const command = `docker service ps ${appName} --format '{{json .}}'`;
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
stderr = result.stderr;
} else {
const result = await execAsync(command);
stdout = result.stdout;
stderr = result.stderr;
}
if (stderr) {
console.error(`Error: ${stderr}`);
return;
}
const appArray = stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return appArray;
} catch (error) {}
};

View File

@@ -4,7 +4,7 @@ import {
backups, backups,
mariadb, mariadb,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { buildMariadb } from "@dokploy/server/utils/databases/mariadb"; import { buildMariadb } from "@dokploy/server/utils/databases/mariadb";
import { pullImage } from "@dokploy/server/utils/docker/utils"; import { pullImage } from "@dokploy/server/utils/docker/utils";
@@ -17,17 +17,14 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
export type Mariadb = typeof mariadb.$inferSelect; export type Mariadb = typeof mariadb.$inferSelect;
export const createMariadb = async (input: typeof apiCreateMariaDB._type) => { export const createMariadb = async (input: typeof apiCreateMariaDB._type) => {
input.appName = const appName = buildAppName("mariadb", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("mariadb");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) { const valid = await validUniqueServerAppName(input.appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Service with this 'AppName' already exists", code: "CONFLICT",
}); message: "Service with this 'AppName' already exists",
} });
} }
const newMariadb = await db const newMariadb = await db
@@ -40,6 +37,7 @@ export const createMariadb = async (input: typeof apiCreateMariaDB._type) => {
databaseRootPassword: input.databaseRootPassword databaseRootPassword: input.databaseRootPassword
? input.databaseRootPassword ? input.databaseRootPassword
: generatePassword(), : generatePassword(),
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -82,10 +80,11 @@ export const updateMariadbById = async (
mariadbId: string, mariadbId: string,
mariadbData: Partial<Mariadb>, mariadbData: Partial<Mariadb>,
) => { ) => {
const { appName, ...rest } = mariadbData;
const result = await db const result = await db
.update(mariadb) .update(mariadb)
.set({ .set({
...mariadbData, ...rest,
}) })
.where(eq(mariadb.mariadbId, mariadbId)) .where(eq(mariadb.mariadbId, mariadbId))
.returning(); .returning();

View File

@@ -1,6 +1,6 @@
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { type apiCreateMongo, backups, mongo } from "@dokploy/server/db/schema"; import { type apiCreateMongo, backups, mongo } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { buildMongo } from "@dokploy/server/utils/databases/mongo"; import { buildMongo } from "@dokploy/server/utils/databases/mongo";
import { pullImage } from "@dokploy/server/utils/docker/utils"; import { pullImage } from "@dokploy/server/utils/docker/utils";
@@ -13,17 +13,14 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
export type Mongo = typeof mongo.$inferSelect; export type Mongo = typeof mongo.$inferSelect;
export const createMongo = async (input: typeof apiCreateMongo._type) => { export const createMongo = async (input: typeof apiCreateMongo._type) => {
input.appName = const appName = buildAppName("mongo", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("mongo");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) { const valid = await validUniqueServerAppName(appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Service with this 'AppName' already exists", code: "CONFLICT",
}); message: "Service with this 'AppName' already exists",
} });
} }
const newMongo = await db const newMongo = await db
@@ -33,6 +30,7 @@ export const createMongo = async (input: typeof apiCreateMongo._type) => {
databasePassword: input.databasePassword databasePassword: input.databasePassword
? input.databasePassword ? input.databasePassword
: generatePassword(), : generatePassword(),
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -74,10 +72,11 @@ export const updateMongoById = async (
mongoId: string, mongoId: string,
mongoData: Partial<Mongo>, mongoData: Partial<Mongo>,
) => { ) => {
const { appName, ...rest } = mongoData;
const result = await db const result = await db
.update(mongo) .update(mongo)
.set({ .set({
...mongoData, ...rest,
}) })
.where(eq(mongo.mongoId, mongoId)) .where(eq(mongo.mongoId, mongoId))
.returning(); .returning();

View File

@@ -1,6 +1,6 @@
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { type apiCreateMySql, backups, mysql } from "@dokploy/server/db/schema"; import { type apiCreateMySql, backups, mysql } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { buildMysql } from "@dokploy/server/utils/databases/mysql"; import { buildMysql } from "@dokploy/server/utils/databases/mysql";
import { pullImage } from "@dokploy/server/utils/docker/utils"; import { pullImage } from "@dokploy/server/utils/docker/utils";
@@ -13,18 +13,14 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
export type MySql = typeof mysql.$inferSelect; export type MySql = typeof mysql.$inferSelect;
export const createMysql = async (input: typeof apiCreateMySql._type) => { export const createMysql = async (input: typeof apiCreateMySql._type) => {
input.appName = const appName = buildAppName("mysql", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("mysql");
if (input.appName) { const valid = await validUniqueServerAppName(appName);
const valid = await validUniqueServerAppName(input.appName); if (!valid) {
throw new TRPCError({
if (!valid) { code: "CONFLICT",
throw new TRPCError({ message: "Service with this 'AppName' already exists",
code: "CONFLICT", });
message: "Service with this 'AppName' already exists",
});
}
} }
const newMysql = await db const newMysql = await db
@@ -37,6 +33,7 @@ export const createMysql = async (input: typeof apiCreateMySql._type) => {
databaseRootPassword: input.databaseRootPassword databaseRootPassword: input.databaseRootPassword
? input.databaseRootPassword ? input.databaseRootPassword
: generatePassword(), : generatePassword(),
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -79,10 +76,11 @@ export const updateMySqlById = async (
mysqlId: string, mysqlId: string,
mysqlData: Partial<MySql>, mysqlData: Partial<MySql>,
) => { ) => {
const { appName, ...rest } = mysqlData;
const result = await db const result = await db
.update(mysql) .update(mysql)
.set({ .set({
...mysqlData, ...rest,
}) })
.where(eq(mysql.mysqlId, mysqlId)) .where(eq(mysql.mysqlId, mysqlId))
.returning(); .returning();

View File

@@ -4,7 +4,7 @@ import {
backups, backups,
postgres, postgres,
} from "@dokploy/server/db/schema"; } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { buildPostgres } from "@dokploy/server/utils/databases/postgres"; import { buildPostgres } from "@dokploy/server/utils/databases/postgres";
import { pullImage } from "@dokploy/server/utils/docker/utils"; import { pullImage } from "@dokploy/server/utils/docker/utils";
@@ -17,17 +17,14 @@ import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
export type Postgres = typeof postgres.$inferSelect; export type Postgres = typeof postgres.$inferSelect;
export const createPostgres = async (input: typeof apiCreatePostgres._type) => { export const createPostgres = async (input: typeof apiCreatePostgres._type) => {
input.appName = const appName = buildAppName("postgres", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("postgres");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) { const valid = await validUniqueServerAppName(appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Service with this 'AppName' already exists", code: "CONFLICT",
}); message: "Service with this 'AppName' already exists",
} });
} }
const newPostgres = await db const newPostgres = await db
@@ -37,6 +34,7 @@ export const createPostgres = async (input: typeof apiCreatePostgres._type) => {
databasePassword: input.databasePassword databasePassword: input.databasePassword
? input.databasePassword ? input.databasePassword
: generatePassword(), : generatePassword(),
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -96,10 +94,11 @@ export const updatePostgresById = async (
postgresId: string, postgresId: string,
postgresData: Partial<Postgres>, postgresData: Partial<Postgres>,
) => { ) => {
const { appName, ...rest } = postgresData;
const result = await db const result = await db
.update(postgres) .update(postgres)
.set({ .set({
...postgresData, ...rest,
}) })
.where(eq(postgres.postgresId, postgresId)) .where(eq(postgres.postgresId, postgresId))
.returning(); .returning();

View File

@@ -7,20 +7,20 @@ import {
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import { slugify } from "../setup/server-setup"; import { slugify } from "../setup/server-setup";
import { findApplicationById } from "./application";
import { createDomain } from "./domain";
import { generatePassword, generateRandomDomain } from "../templates/utils"; import { generatePassword, generateRandomDomain } from "../templates/utils";
import { removeService } from "../utils/docker/utils";
import { removeDirectoryCode } from "../utils/filesystem/directory";
import { authGithub } from "../utils/providers/github";
import { removeTraefikConfig } from "../utils/traefik/application";
import { manageDomain } from "../utils/traefik/domain"; import { manageDomain } from "../utils/traefik/domain";
import { findAdminById } from "./admin";
import { findApplicationById } from "./application";
import { import {
removeDeployments, removeDeployments,
removeDeploymentsByPreviewDeploymentId, removeDeploymentsByPreviewDeploymentId,
} from "./deployment"; } from "./deployment";
import { removeDirectoryCode } from "../utils/filesystem/directory"; import { createDomain } from "./domain";
import { removeTraefikConfig } from "../utils/traefik/application"; import { type Github, getIssueComment } from "./github";
import { removeService } from "../utils/docker/utils";
import { authGithub } from "../utils/providers/github";
import { getIssueComment, type Github } from "./github";
import { findAdminById } from "./admin";
export type PreviewDeployment = typeof previewDeployments.$inferSelect; export type PreviewDeployment = typeof previewDeployments.$inferSelect;

View File

@@ -1,6 +1,6 @@
import { db } from "@dokploy/server/db"; import { db } from "@dokploy/server/db";
import { type apiCreateRedis, redis } from "@dokploy/server/db/schema"; import { type apiCreateRedis, redis } from "@dokploy/server/db/schema";
import { generateAppName } from "@dokploy/server/db/schema"; import { buildAppName, cleanAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates/utils"; import { generatePassword } from "@dokploy/server/templates/utils";
import { buildRedis } from "@dokploy/server/utils/databases/redis"; import { buildRedis } from "@dokploy/server/utils/databases/redis";
import { pullImage } from "@dokploy/server/utils/docker/utils"; import { pullImage } from "@dokploy/server/utils/docker/utils";
@@ -14,17 +14,14 @@ export type Redis = typeof redis.$inferSelect;
// https://github.com/drizzle-team/drizzle-orm/discussions/1483#discussioncomment-7523881 // https://github.com/drizzle-team/drizzle-orm/discussions/1483#discussioncomment-7523881
export const createRedis = async (input: typeof apiCreateRedis._type) => { export const createRedis = async (input: typeof apiCreateRedis._type) => {
input.appName = const appName = buildAppName("redis", input.appName);
`${input.appName}-${generatePassword(6)}` || generateAppName("redis");
if (input.appName) {
const valid = await validUniqueServerAppName(input.appName);
if (!valid) { const valid = await validUniqueServerAppName(appName);
throw new TRPCError({ if (!valid) {
code: "CONFLICT", throw new TRPCError({
message: "Service with this 'AppName' already exists", code: "CONFLICT",
}); message: "Service with this 'AppName' already exists",
} });
} }
const newRedis = await db const newRedis = await db
@@ -34,6 +31,7 @@ export const createRedis = async (input: typeof apiCreateRedis._type) => {
databasePassword: input.databasePassword databasePassword: input.databasePassword
? input.databasePassword ? input.databasePassword
: generatePassword(), : generatePassword(),
appName,
}) })
.returning() .returning()
.then((value) => value[0]); .then((value) => value[0]);
@@ -70,10 +68,11 @@ export const updateRedisById = async (
redisId: string, redisId: string,
redisData: Partial<Redis>, redisData: Partial<Redis>,
) => { ) => {
const { appName, ...rest } = redisData;
const result = await db const result = await db
.update(redis) .update(redis)
.set({ .set({
...redisData, ...rest,
}) })
.where(eq(redis.redisId, redisId)) .where(eq(redis.redisId, redisId))
.returning(); .returning();

View File

@@ -1,41 +1,108 @@
import { readdirSync } from "node:fs"; import { readdirSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { docker } from "@dokploy/server/constants"; import { docker } from "@dokploy/server/constants";
import { getServiceContainer } from "@dokploy/server/utils/docker/utils"; import {
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
// import packageInfo from "../../../package.json"; // import packageInfo from "../../../package.json";
const updateIsAvailable = async () => { export interface IUpdateData {
try { latestVersion: string | null;
const service = await getServiceContainer("dokploy"); updateAvailable: boolean;
}
const localImage = await docker.getImage(getDokployImage()).inspect(); export const DEFAULT_UPDATE_DATA: IUpdateData = {
return localImage.Id !== service?.ImageID; latestVersion: null,
} catch (error) { updateAvailable: false,
return false; };
}
/** Returns current Dokploy docker image tag or `latest` by default. */
export const getDokployImageTag = () => {
return process.env.RELEASE_TAG || "latest";
}; };
export const getDokployImage = () => { export const getDokployImage = () => {
return `dokploy/dokploy:${process.env.RELEASE_TAG || "latest"}`; return `dokploy/dokploy:${getDokployImageTag()}`;
}; };
export const pullLatestRelease = async () => { export const pullLatestRelease = async () => {
try { const stream = await docker.pull(getDokployImage());
const stream = await docker.pull(getDokployImage(), {}); await new Promise((resolve, reject) => {
await new Promise((resolve, reject) => { docker.modem.followProgress(stream, (err, res) =>
docker.modem.followProgress(stream, (err, res) => err ? reject(err) : resolve(res),
err ? reject(err) : resolve(res), );
); });
});
const newUpdateIsAvailable = await updateIsAvailable();
return newUpdateIsAvailable;
} catch (error) {}
return false;
}; };
export const getDokployVersion = () => {
// return packageInfo.version; /** Returns Dokploy docker service image digest */
export const getServiceImageDigest = async () => {
const { stdout } = await execAsync(
"docker service inspect dokploy --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'",
);
const currentDigest = stdout.trim().split("@")[1];
if (!currentDigest) {
throw new Error("Could not get current service image digest");
}
return currentDigest;
};
/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */
export const getUpdateData = async (): Promise<IUpdateData> => {
let currentDigest: string;
try {
currentDigest = await getServiceImageDigest();
} catch {
// Docker service might not exist locally
// You can run the # Installation command for docker service create mentioned in the below docs to test it locally:
// https://docs.dokploy.com/docs/core/manual-installation
return DEFAULT_UPDATE_DATA;
}
const baseUrl = "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
let url: string | null = `${baseUrl}?page_size=100`;
let allResults: { digest: string; name: string }[] = [];
while (url) {
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const data = (await response.json()) as {
next: string | null;
results: { digest: string; name: string }[];
};
allResults = allResults.concat(data.results);
url = data?.next;
}
const imageTag = getDokployImageTag();
const searchedDigest = allResults.find((t) => t.name === imageTag)?.digest;
if (!searchedDigest) {
return DEFAULT_UPDATE_DATA;
}
if (imageTag === "latest") {
const versionedTag = allResults.find(
(t) => t.digest === searchedDigest && t.name.startsWith("v"),
);
if (!versionedTag) {
return DEFAULT_UPDATE_DATA;
}
const { name: latestVersion, digest } = versionedTag;
const updateAvailable = digest !== currentDigest;
return { latestVersion, updateAvailable };
}
const updateAvailable = searchedDigest !== currentDigest;
return { latestVersion: imageTag, updateAvailable };
}; };
interface TreeDataItem { interface TreeDataItem {

View File

@@ -16,12 +16,18 @@ interface TraefikOptions {
enableDashboard?: boolean; enableDashboard?: boolean;
env?: string[]; env?: string[];
serverId?: string; serverId?: string;
additionalPorts?: {
targetPort: number;
publishedPort: number;
publishMode?: "ingress" | "host";
}[];
} }
export const initializeTraefik = async ({ export const initializeTraefik = async ({
enableDashboard = false, enableDashboard = false,
env, env,
serverId, serverId,
additionalPorts = [],
}: TraefikOptions = {}) => { }: TraefikOptions = {}) => {
const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId); const { MAIN_TRAEFIK_PATH, DYNAMIC_TRAEFIK_PATH } = paths(!!serverId);
const imageName = "traefik:v3.1.2"; const imageName = "traefik:v3.1.2";
@@ -84,6 +90,11 @@ export const initializeTraefik = async ({
}, },
] ]
: []), : []),
...additionalPorts.map((port) => ({
TargetPort: port.targetPort,
PublishedPort: port.publishedPort,
PublishMode: port.publishMode || ("host" as const),
})),
], ],
}, },
}; };

View File

@@ -11,6 +11,8 @@ import { runMariadbBackup } from "./mariadb";
import { runMongoBackup } from "./mongo"; import { runMongoBackup } from "./mongo";
import { runMySqlBackup } from "./mysql"; import { runMySqlBackup } from "./mysql";
import { runPostgresBackup } from "./postgres"; import { runPostgresBackup } from "./postgres";
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
export const initCronJobs = async () => { export const initCronJobs = async () => {
console.log("Setting up cron jobs...."); console.log("Setting up cron jobs....");
@@ -25,14 +27,15 @@ export const initCronJobs = async () => {
await cleanUpUnusedImages(); await cleanUpUnusedImages();
await cleanUpDockerBuilder(); await cleanUpDockerBuilder();
await cleanUpSystemPrune(); await cleanUpSystemPrune();
await sendDockerCleanupNotifications(admin.adminId);
}); });
} }
const servers = await getAllServers(); const servers = await getAllServers();
for (const server of servers) { for (const server of servers) {
const { appName, serverId } = server; const { appName, serverId, enableDockerCleanup } = server;
if (serverId) { if (enableDockerCleanup) {
scheduleJob(serverId, "0 0 * * *", async () => { scheduleJob(serverId, "0 0 * * *", async () => {
console.log( console.log(
`SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${appName}`, `SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${appName}`,
@@ -40,12 +43,17 @@ export const initCronJobs = async () => {
await cleanUpUnusedImages(serverId); await cleanUpUnusedImages(serverId);
await cleanUpDockerBuilder(serverId); await cleanUpDockerBuilder(serverId);
await cleanUpSystemPrune(serverId); await cleanUpSystemPrune(serverId);
await sendDockerCleanupNotifications(
admin.adminId,
`Docker cleanup for Server ${appName}`,
);
}); });
} }
} }
const pgs = await db.query.postgres.findMany({ const pgs = await db.query.postgres.findMany({
with: { with: {
project: true,
backups: { backups: {
with: { with: {
destination: true, destination: true,
@@ -61,18 +69,39 @@ export const initCronJobs = async () => {
for (const backup of pg.backups) { for (const backup of pg.backups) {
const { schedule, backupId, enabled } = backup; const { schedule, backupId, enabled } = backup;
if (enabled) { if (enabled) {
scheduleJob(backupId, schedule, async () => { try {
console.log( scheduleJob(backupId, schedule, async () => {
`PG-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, console.log(
); `PG-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,
runPostgresBackup(pg, backup); );
}); runPostgresBackup(pg, backup);
});
await sendDatabaseBackupNotifications({
applicationName: pg.name,
projectName: pg.project.name,
databaseType: "postgres",
type: "success",
adminId: pg.project.adminId,
});
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: pg.name,
projectName: pg.project.name,
databaseType: "postgres",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
adminId: pg.project.adminId,
});
}
} }
} }
} }
const mariadbs = await db.query.mariadb.findMany({ const mariadbs = await db.query.mariadb.findMany({
with: { with: {
project: true,
backups: { backups: {
with: { with: {
destination: true, destination: true,
@@ -89,18 +118,38 @@ export const initCronJobs = async () => {
for (const backup of maria.backups) { for (const backup of maria.backups) {
const { schedule, backupId, enabled } = backup; const { schedule, backupId, enabled } = backup;
if (enabled) { if (enabled) {
scheduleJob(backupId, schedule, async () => { try {
console.log( scheduleJob(backupId, schedule, async () => {
`MARIADB-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, console.log(
); `MARIADB-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,
await runMariadbBackup(maria, backup); );
}); await runMariadbBackup(maria, backup);
});
await sendDatabaseBackupNotifications({
applicationName: maria.name,
projectName: maria.project.name,
databaseType: "mariadb",
type: "success",
adminId: maria.project.adminId,
});
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: maria.name,
projectName: maria.project.name,
databaseType: "mariadb",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
adminId: maria.project.adminId,
});
}
} }
} }
} }
const mongodbs = await db.query.mongo.findMany({ const mongodbs = await db.query.mongo.findMany({
with: { with: {
project: true,
backups: { backups: {
with: { with: {
destination: true, destination: true,
@@ -117,18 +166,38 @@ export const initCronJobs = async () => {
for (const backup of mongo.backups) { for (const backup of mongo.backups) {
const { schedule, backupId, enabled } = backup; const { schedule, backupId, enabled } = backup;
if (enabled) { if (enabled) {
scheduleJob(backupId, schedule, async () => { try {
console.log( scheduleJob(backupId, schedule, async () => {
`MONGO-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, console.log(
); `MONGO-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,
await runMongoBackup(mongo, backup); );
}); await runMongoBackup(mongo, backup);
});
await sendDatabaseBackupNotifications({
applicationName: mongo.name,
projectName: mongo.project.name,
databaseType: "mongodb",
type: "success",
adminId: mongo.project.adminId,
});
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: mongo.name,
projectName: mongo.project.name,
databaseType: "mongodb",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
adminId: mongo.project.adminId,
});
}
} }
} }
} }
const mysqls = await db.query.mysql.findMany({ const mysqls = await db.query.mysql.findMany({
with: { with: {
project: true,
backups: { backups: {
with: { with: {
destination: true, destination: true,
@@ -145,12 +214,31 @@ export const initCronJobs = async () => {
for (const backup of mysql.backups) { for (const backup of mysql.backups) {
const { schedule, backupId, enabled } = backup; const { schedule, backupId, enabled } = backup;
if (enabled) { if (enabled) {
scheduleJob(backupId, schedule, async () => { try {
console.log( scheduleJob(backupId, schedule, async () => {
`MYSQL-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`, console.log(
); `MYSQL-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,
await runMySqlBackup(mysql, backup); );
}); await runMySqlBackup(mysql, backup);
});
await sendDatabaseBackupNotifications({
applicationName: mysql.name,
projectName: mysql.project.name,
databaseType: "mysql",
type: "success",
adminId: mysql.project.adminId,
});
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: mysql.name,
projectName: mysql.project.name,
databaseType: "mysql",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
adminId: mysql.project.adminId,
});
}
} }
} }
} }

View File

@@ -48,6 +48,7 @@ Compose Type: ${composeType} ✅`;
writeStream.write(`\n${logBox}\n`); writeStream.write(`\n${logBox}\n`);
const projectPath = join(COMPOSE_PATH, compose.appName, "code"); const projectPath = join(COMPOSE_PATH, compose.appName, "code");
await spawnAsync( await spawnAsync(
"docker", "docker",
[...command.split(" ")], [...command.split(" ")],
@@ -144,6 +145,10 @@ const sanitizeCommand = (command: string) => {
export const createCommand = (compose: ComposeNested) => { export const createCommand = (compose: ComposeNested) => {
const { composeType, appName, sourceType } = compose; const { composeType, appName, sourceType } = compose;
if (compose.command) {
return `${sanitizeCommand(compose.command)}`;
}
const path = const path =
sourceType === "raw" ? "docker-compose.yml" : compose.composePath; sourceType === "raw" ? "docker-compose.yml" : compose.composePath;
let command = ""; let command = "";
@@ -154,12 +159,6 @@ export const createCommand = (compose: ComposeNested) => {
command = `stack deploy -c ${path} ${appName} --prune`; command = `stack deploy -c ${path} ${appName} --prune`;
} }
const customCommand = sanitizeCommand(compose.command);
if (customCommand) {
command = `${command} ${customCommand}`;
}
return command; return command;
}; };

View File

@@ -211,21 +211,21 @@ const getImageName = (application: ApplicationNested) => {
} }
if (registry) { if (registry) {
return join(registry.imagePrefix || "", appName); return join(registry.registryUrl, registry.imagePrefix || "", appName);
} }
return `${appName}:latest`; return `${appName}:latest`;
}; };
const getAuthConfig = (application: ApplicationNested) => { const getAuthConfig = (application: ApplicationNested) => {
const { registry, username, password, sourceType } = application; const { registry, username, password, sourceType, registryUrl } = application;
if (sourceType === "docker") { if (sourceType === "docker") {
if (username && password) { if (username && password) {
return { return {
password, password,
username, username,
serveraddress: "https://index.docker.io/v1/", serveraddress: registryUrl || "",
}; };
} }
} else if (registry) { } else if (registry) {

View File

@@ -1,5 +1,5 @@
import type { WriteStream } from "node:fs"; import type { WriteStream } from "node:fs";
import { join } from "node:path"; import path, { join } from "node:path";
import type { ApplicationNested } from "../builders"; import type { ApplicationNested } from "../builders";
import { spawnAsync } from "../process/spawnAsync"; import { spawnAsync } from "../process/spawnAsync";
@@ -13,27 +13,32 @@ export const uploadImage = async (
throw new Error("Registry not found"); throw new Error("Registry not found");
} }
const { registryUrl, imagePrefix, registryType } = registry; const { registryUrl, imagePrefix } = registry;
const { appName } = application; const { appName } = application;
const imageName = `${appName}:latest`; const imageName = `${appName}:latest`;
const finalURL = registryUrl; const finalURL = registryUrl;
const registryTag = join(imagePrefix || "", imageName); const registryTag = path
.join(registryUrl, join(imagePrefix || "", imageName))
.replace(/\/+/g, "/");
try { try {
writeStream.write( writeStream.write(
`📦 [Enabled Registry] Uploading image to ${registry.registryType} | ${registryTag} | ${finalURL}\n`, `📦 [Enabled Registry] Uploading image to ${registry.registryType} | ${imageName} | ${finalURL}\n`,
); );
await spawnAsync( const loginCommand = spawnAsync(
"docker", "docker",
["login", finalURL, "-u", registry.username, "-p", registry.password], ["login", finalURL, "-u", registry.username, "--password-stdin"],
(data) => { (data) => {
if (writeStream.writable) { if (writeStream.writable) {
writeStream.write(data); writeStream.write(data);
} }
}, },
); );
loginCommand.child?.stdin?.write(registry.password);
loginCommand.child?.stdin?.end();
await loginCommand;
await spawnAsync("docker", ["tag", imageName, registryTag], (data) => { await spawnAsync("docker", ["tag", imageName, registryTag], (data) => {
if (writeStream.writable) { if (writeStream.writable) {
@@ -68,22 +73,23 @@ export const uploadImageRemoteCommand = (
const finalURL = registryUrl; const finalURL = registryUrl;
const registryTag = join(imagePrefix || "", imageName); const registryTag = path
.join(registryUrl, join(imagePrefix || "", imageName))
.replace(/\/+/g, "/");
try { try {
const command = ` const command = `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" >> ${logPath}; echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" >> ${logPath};
docker login ${finalURL} -u ${registry.username} -p ${registry.password} >> ${logPath} 2>> ${logPath} || { echo "${registry.password}" | docker login ${finalURL} -u ${registry.username} --password-stdin >> ${logPath} 2>> ${logPath} || {
echo "❌ DockerHub Failed" >> ${logPath}; echo "❌ DockerHub Failed" >> ${logPath};
exit 1; exit 1;
} }
echo "✅ DockerHub Login Success" >> ${logPath}; echo "✅ Registry Login Success" >> ${logPath};
docker tag ${imageName} ${registryTag} >> ${logPath} 2>> ${logPath} || { docker tag ${imageName} ${registryTag} >> ${logPath} 2>> ${logPath} || {
echo "❌ Error tagging image" >> ${logPath}; echo "❌ Error tagging image" >> ${logPath};
exit 1; exit 1;
} }
echo "✅ Image Tagged" >> ${logPath}; echo "✅ Image Tagged" >> ${logPath};
docker push ${registryTag} 2>> ${logPath} || { docker push ${registryTag} 2>> ${logPath} || {
echo "❌ Error pushing image" >> ${logPath}; echo "❌ Error pushing image" >> ${logPath};
exit 1; exit 1;
@@ -92,7 +98,6 @@ export const uploadImageRemoteCommand = (
`; `;
return command; return command;
} catch (error) { } catch (error) {
console.log(error);
throw error; throw error;
} }
}; };

View File

@@ -28,7 +28,7 @@ export const sendBuildErrorNotifications = async ({
adminId, adminId,
}: Props) => { }: Props) => {
const date = new Date(); const date = new Date();
const unixDate = ~~((Number(date)) / 1000); const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({ const notificationList = await db.query.notifications.findMany({
where: and( where: and(
eq(notifications.appBuildError, true), eq(notifications.appBuildError, true),
@@ -60,45 +60,45 @@ export const sendBuildErrorNotifications = async ({
if (discord) { if (discord) {
await sendDiscordNotification(discord, { await sendDiscordNotification(discord, {
title: "> `⚠️` - Build Failed", title: "> `⚠️` Build Failed",
color: 0xed4245, color: 0xed4245,
fields: [ fields: [
{ {
name: "`🛠️`Project", name: "`🛠️` Project",
value: projectName, value: projectName,
inline: true, inline: true,
}, },
{ {
name: "`⚙️`Application", name: "`⚙️` Application",
value: applicationName, value: applicationName,
inline: true, inline: true,
}, },
{ {
name: "`❔`Type", name: "`❔` Type",
value: applicationType, value: applicationType,
inline: true, inline: true,
}, },
{ {
name: "`📅`Date", name: "`📅` Date",
value: `<t:${unixDate}:D>`, value: `<t:${unixDate}:D>`,
inline: true, inline: true,
}, },
{ {
name: "`⌚`Time", name: "`⌚` Time",
value: `<t:${unixDate}:t>`, value: `<t:${unixDate}:t>`,
inline: true, inline: true,
}, },
{ {
name: "`❓`Type", name: "`❓`Type",
value: "Failed", value: "Failed",
inline: true, inline: true,
}, },
{ {
name: "`⚠️`Error Message", name: "`⚠️` Error Message",
value: `\`\`\`${errorMessage}\`\`\``, value: `\`\`\`${errorMessage}\`\`\``,
}, },
{ {
name: "`🧷`Build Link", name: "`🧷` Build Link",
value: `[Click here to access build link](${buildLink})`, value: `[Click here to access build link](${buildLink})`,
}, },
], ],

View File

@@ -26,7 +26,7 @@ export const sendBuildSuccessNotifications = async ({
adminId, adminId,
}: Props) => { }: Props) => {
const date = new Date(); const date = new Date();
const unixDate = ~~((Number(date)) / 1000); const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({ const notificationList = await db.query.notifications.findMany({
where: and( where: and(
eq(notifications.appDeploy, true), eq(notifications.appDeploy, true),
@@ -58,41 +58,41 @@ export const sendBuildSuccessNotifications = async ({
if (discord) { if (discord) {
await sendDiscordNotification(discord, { await sendDiscordNotification(discord, {
title: "> `✅` - Build Success", title: "> `✅` Build Success",
color: 0x57f287, color: 0x57f287,
fields: [ fields: [
{ {
name: "`🛠️`Project", name: "`🛠️` Project",
value: projectName, value: projectName,
inline: true, inline: true,
}, },
{ {
name: "`⚙️`Application", name: "`⚙️` Application",
value: applicationName, value: applicationName,
inline: true, inline: true,
}, },
{ {
name: "`❔`Application Type", name: "`❔` Application Type",
value: applicationType, value: applicationType,
inline: true, inline: true,
}, },
{ {
name: "`📅`Date", name: "`📅` Date",
value: `<t:${unixDate}:D>`, value: `<t:${unixDate}:D>`,
inline: true, inline: true,
}, },
{ {
name: "`⌚`Time", name: "`⌚` Time",
value: `<t:${unixDate}:t>`, value: `<t:${unixDate}:t>`,
inline: true, inline: true,
}, },
{ {
name: "`❓`Type", name: "`❓` Type",
value: "Successful", value: "Successful",
inline: true, inline: true,
}, },
{ {
name: "`🧷`Build Link", name: "`🧷` Build Link",
value: `[Click here to access build link](${buildLink})`, value: `[Click here to access build link](${buildLink})`,
}, },
], ],

View File

@@ -26,7 +26,7 @@ export const sendDatabaseBackupNotifications = async ({
errorMessage?: string; errorMessage?: string;
}) => { }) => {
const date = new Date(); const date = new Date();
const unixDate = ~~((Number(date)) / 1000); const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({ const notificationList = await db.query.notifications.findMany({
where: and( where: and(
eq(notifications.databaseBackup, true), eq(notifications.databaseBackup, true),
@@ -65,37 +65,37 @@ export const sendDatabaseBackupNotifications = async ({
await sendDiscordNotification(discord, { await sendDiscordNotification(discord, {
title: title:
type === "success" type === "success"
? "> `✅` - Database Backup Successful" ? "> `✅` Database Backup Successful"
: "> `❌` - Database Backup Failed", : "> `❌` Database Backup Failed",
color: type === "success" ? 0x57f287 : 0xed4245, color: type === "success" ? 0x57f287 : 0xed4245,
fields: [ fields: [
{ {
name: "`🛠️`Project", name: "`🛠️` Project",
value: projectName, value: projectName,
inline: true, inline: true,
}, },
{ {
name: "`⚙️`Application", name: "`⚙️` Application",
value: applicationName, value: applicationName,
inline: true, inline: true,
}, },
{ {
name: "`❔`Database", name: "`❔` Database",
value: databaseType, value: databaseType,
inline: true, inline: true,
}, },
{ {
name: "`📅`Date", name: "`📅` Date",
value: `<t:${unixDate}:D>`, value: `<t:${unixDate}:D>`,
inline: true, inline: true,
}, },
{ {
name: "`⌚`Time", name: "`⌚` Time",
value: `<t:${unixDate}:t>`, value: `<t:${unixDate}:t>`,
inline: true, inline: true,
}, },
{ {
name: "`❓`Type", name: "`❓` Type",
value: type value: type
.replace("error", "Failed") .replace("error", "Failed")
.replace("success", "Successful"), .replace("success", "Successful"),
@@ -104,7 +104,7 @@ export const sendDatabaseBackupNotifications = async ({
...(type === "error" && errorMessage ...(type === "error" && errorMessage
? [ ? [
{ {
name: "`⚠️`Error Message", name: "`⚠️` Error Message",
value: `\`\`\`${errorMessage}\`\`\``, value: `\`\`\`${errorMessage}\`\`\``,
}, },
] ]

View File

@@ -15,7 +15,7 @@ export const sendDockerCleanupNotifications = async (
message = "Docker cleanup for dokploy", message = "Docker cleanup for dokploy",
) => { ) => {
const date = new Date(); const date = new Date();
const unixDate = ~~((Number(date)) / 1000); const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({ const notificationList = await db.query.notifications.findMany({
where: and( where: and(
eq(notifications.dockerCleanup, true), eq(notifications.dockerCleanup, true),
@@ -46,26 +46,26 @@ export const sendDockerCleanupNotifications = async (
if (discord) { if (discord) {
await sendDiscordNotification(discord, { await sendDiscordNotification(discord, {
title: "> `✅` - Docker Cleanup", title: "> `✅` Docker Cleanup",
color: 0x57f287, color: 0x57f287,
fields: [ fields: [
{ {
name: "`📅`Date", name: "`📅` Date",
value: `<t:${unixDate}:D>`, value: `<t:${unixDate}:D>`,
inline: true, inline: true,
}, },
{ {
name: "`⌚`Time", name: "`⌚` Time",
value: `<t:${unixDate}:t>`, value: `<t:${unixDate}:t>`,
inline: true, inline: true,
}, },
{ {
name: "`❓`Type", name: "`❓` Type",
value: "Successful", value: "Successful",
inline: true, inline: true,
}, },
{ {
name: "`📜`Message", name: "`📜` Message",
value: `\`\`\`${message}\`\`\``, value: `\`\`\`${message}\`\`\``,
}, },
], ],

View File

@@ -12,7 +12,7 @@ import {
export const sendDokployRestartNotifications = async () => { export const sendDokployRestartNotifications = async () => {
const date = new Date(); const date = new Date();
const unixDate = ~~((Number(date)) / 1000); const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({ const notificationList = await db.query.notifications.findMany({
where: eq(notifications.dokployRestart, true), where: eq(notifications.dokployRestart, true),
with: { with: {
@@ -35,21 +35,21 @@ export const sendDokployRestartNotifications = async () => {
if (discord) { if (discord) {
await sendDiscordNotification(discord, { await sendDiscordNotification(discord, {
title: "> `✅` - Dokploy Server Restarted", title: "> `✅` Dokploy Server Restarted",
color: 0x57f287, color: 0x57f287,
fields: [ fields: [
{ {
name: "`📅`Date", name: "`📅` Date",
value: `<t:${unixDate}:D>`, value: `<t:${unixDate}:D>`,
inline: true, inline: true,
}, },
{ {
name: "`⌚`Time", name: "`⌚` Time",
value: `<t:${unixDate}:t>`, value: `<t:${unixDate}:t>`,
inline: true, inline: true,
}, },
{ {
name: "`❓`Type", name: "`❓` Type",
value: "Successful", value: "Successful",
inline: true, inline: true,
}, },

View File

@@ -53,7 +53,7 @@ export const buildRemoteDocker = async (
application: ApplicationNested, application: ApplicationNested,
logPath: string, logPath: string,
) => { ) => {
const { sourceType, dockerImage, username, password } = application; const { registryUrl, dockerImage, username, password } = application;
try { try {
if (!dockerImage) { if (!dockerImage) {
@@ -65,7 +65,7 @@ echo "Pulling ${dockerImage}" >> ${logPath};
if (username && password) { if (username && password) {
command += ` command += `
if ! docker login --username ${username} --password ${password} https://index.docker.io/v1/ >> ${logPath} 2>&1; then if ! echo "${password}" | docker login --username "${username}" --password-stdin "${registryUrl || ""}" >> ${logPath} 2>&1; then
echo "❌ Login failed" >> ${logPath}; echo "❌ Login failed" >> ${logPath};
exit 1; exit 1;
fi fi

17
pnpm-lock.yaml generated
View File

@@ -250,6 +250,9 @@ importers:
drizzle-zod: drizzle-zod:
specifier: 0.5.1 specifier: 0.5.1
version: 0.5.1(drizzle-orm@0.30.10(@types/react@18.3.5)(postgres@3.4.4)(react@18.2.0))(zod@3.23.8) version: 0.5.1(drizzle-orm@0.30.10(@types/react@18.3.5)(postgres@3.4.4)(react@18.2.0))(zod@3.23.8)
fancy-ansi:
specifier: ^0.1.3
version: 0.1.3
i18next: i18next:
specifier: ^23.16.4 specifier: ^23.16.4
version: 23.16.5 version: 23.16.5
@@ -874,9 +877,11 @@ packages:
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is'
'@esbuild-kit/esm-loader@2.6.5': '@esbuild-kit/esm-loader@2.6.5':
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
deprecated: 'Merged into tsx: https://tsx.is'
'@esbuild/aix-ppc64@0.19.12': '@esbuild/aix-ppc64@0.19.12':
resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==}
@@ -4401,6 +4406,9 @@ packages:
resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==}
engines: {node: '>=6'} engines: {node: '>=6'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@1.0.5: escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'} engines: {node: '>=0.8.0'}
@@ -4460,6 +4468,9 @@ packages:
ext@1.7.0: ext@1.7.0:
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
fancy-ansi@0.1.3:
resolution: {integrity: sha512-tRQVTo5jjdSIiydqgzIIEZpKddzSsfGLsSVt6vWdjVm7fbvDTiQkyoPu6Z3dIPlAM4OZk0jP5jmTCX4G8WGgBw==}
fast-copy@3.0.2: fast-copy@3.0.2:
resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==} resolution: {integrity: sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==}
@@ -10735,6 +10746,8 @@ snapshots:
escalade@3.1.2: {} escalade@3.1.2: {}
escape-html@1.0.3: {}
escape-string-regexp@1.0.5: {} escape-string-regexp@1.0.5: {}
escape-string-regexp@5.0.0: {} escape-string-regexp@5.0.0: {}
@@ -10795,6 +10808,10 @@ snapshots:
dependencies: dependencies:
type: 2.7.3 type: 2.7.3
fancy-ansi@0.1.3:
dependencies:
escape-html: 1.0.3
fast-copy@3.0.2: {} fast-copy@3.0.2: {}
fast-deep-equal@2.0.1: {} fast-deep-equal@2.0.1: {}