Merge pull request #961 from Dokploy/canary

v0.15.1
This commit is contained in:
Mauricio Siu
2024-12-21 15:42:19 -06:00
committed by GitHub
41 changed files with 1805 additions and 559 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

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

@@ -9,7 +9,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
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, parseAnsi } from "./utils";
interface LogLineProps { interface LogLineProps {
log: LogLine; log: LogLine;
@@ -33,18 +33,38 @@ 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) {
const segments = parseAnsi(text);
const parts = text.split(new RegExp(`(${escapeRegExp(term)})`, "gi")); return segments.map((segment, index) => (
return parts.map((part, index) => <span key={index} className={segment.className || undefined}>
part.toLowerCase() === term.toLowerCase() ? ( {segment.text}
<span key={index} className="bg-yellow-200 dark:bg-yellow-900">
{part}
</span> </span>
) : ( ));
part }
),
); // For search, we need to handle both ANSI and search highlighting
const segments = parseAnsi(text);
return segments.map((segment, index) => {
const parts = segment.text.split(
new RegExp(`(${escapeRegExp(term)})`, "gi"),
);
return (
<span key={index} className={segment.className || undefined}>
{parts.map((part, partIndex) =>
part.toLowerCase() === term.toLowerCase() ? (
<span
key={partIndex}
className="bg-yellow-200 dark:bg-yellow-900"
>
{part}
</span>
) : (
part
),
)}
</span>
);
});
}; };
const tooltip = (color: string, timestamp: string | null) => { const tooltip = (color: string, timestamp: string | null) => {
@@ -104,7 +124,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;
@@ -12,6 +12,47 @@ interface LogStyle {
variant: LogVariant; variant: LogVariant;
color: string; color: string;
} }
interface AnsiSegment {
text: string;
className: string;
}
const ansiToTailwind: Record<number, string> = {
// Reset
0: "",
// Regular colors
30: "text-black dark:text-gray-900",
31: "text-red-600 dark:text-red-500",
32: "text-green-600 dark:text-green-500",
33: "text-yellow-600 dark:text-yellow-500",
34: "text-blue-600 dark:text-blue-500",
35: "text-purple-600 dark:text-purple-500",
36: "text-cyan-600 dark:text-cyan-500",
37: "text-gray-600 dark:text-gray-400",
// Bright colors
90: "text-gray-500 dark:text-gray-600",
91: "text-red-500 dark:text-red-600",
92: "text-green-500 dark:text-green-600",
93: "text-yellow-500 dark:text-yellow-600",
94: "text-blue-500 dark:text-blue-600",
95: "text-purple-500 dark:text-purple-600",
96: "text-cyan-500 dark:text-cyan-600",
97: "text-white dark:text-gray-300",
// Background colors
40: "bg-black",
41: "bg-red-600",
42: "bg-green-600",
43: "bg-yellow-600",
44: "bg-blue-600",
45: "bg-purple-600",
46: "bg-cyan-600",
47: "bg-white",
// Formatting
1: "font-bold",
2: "opacity-75",
3: "italic",
4: "underline",
};
const LOG_STYLES: Record<LogType, LogStyle> = { const LOG_STYLES: Record<LogType, LogStyle> = {
error: { error: {
@@ -138,11 +179,68 @@ 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;
} }
return LOG_STYLES.info; return LOG_STYLES.info;
}; };
export function parseAnsi(text: string) {
const segments: { text: string; className: string }[] = [];
let currentIndex = 0;
let currentClasses: string[] = [];
while (currentIndex < text.length) {
const escStart = text.indexOf("\x1b[", currentIndex);
// No more escape sequences found
if (escStart === -1) {
if (currentIndex < text.length) {
segments.push({
text: text.slice(currentIndex),
className: currentClasses.join(" "),
});
}
break;
}
// Add text before escape sequence
if (escStart > currentIndex) {
segments.push({
text: text.slice(currentIndex, escStart),
className: currentClasses.join(" "),
});
}
const escEnd = text.indexOf("m", escStart);
if (escEnd === -1) break;
// Handle multiple codes in one sequence (e.g., \x1b[1;31m)
const codesStr = text.slice(escStart + 2, escEnd);
const codes = codesStr.split(";").map((c) => Number.parseInt(c, 10));
if (codes.includes(0)) {
// Reset all formatting
currentClasses = [];
} else {
// Add new classes for each code
for (const code of codes) {
const className = ansiToTailwind[code];
if (className && !currentClasses.includes(className)) {
currentClasses.push(className);
}
}
}
currentIndex = escEnd + 1;
}
return segments;
}

View File

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

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

@@ -49,12 +49,12 @@ interface AdditionalPort {
/** /**
* ManageTraefikPorts is a component that provides a modal interface for managing * ManageTraefikPorts is a component that provides a modal interface for managing
* additional port mappings for Traefik in a Docker Swarm environment. * additional port mappings for Traefik in a Docker Swarm environment.
* *
* Features: * Features:
* - Add, remove, and edit port mappings * - Add, remove, and edit port mappings
* - Configure target port, published port, and publish mode for each mapping * - Configure target port, published port, and publish mode for each mapping
* - Persist port configurations through API calls * - Persist port configurations through API calls
* *
* @component * @component
* @example * @example
* ```tsx * ```tsx
@@ -121,7 +121,10 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
{additionalPorts.map((port, index) => ( {additionalPorts.map((port, index) => (
<div key={index} className="grid grid-cols-[120px_120px_minmax(120px,1fr)_80px] gap-4 items-end"> <div
key={index}
className="grid grid-cols-[120px_120px_minmax(120px,1fr)_80px] gap-4 items-end"
>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`target-port-${index}`}> <Label htmlFor={`target-port-${index}`}>
{t("settings.server.webServer.traefik.targetPort")} {t("settings.server.webServer.traefik.targetPort")}
@@ -132,9 +135,13 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
value={port.targetPort} value={port.targetPort}
onChange={(e) => { onChange={(e) => {
const newPorts = [...additionalPorts]; const newPorts = [...additionalPorts];
newPorts[index].targetPort = Number.parseInt(
e.target.value, if (newPorts[index]) {
); newPorts[index].targetPort = Number.parseInt(
e.target.value,
);
}
setAdditionalPorts(newPorts); setAdditionalPorts(newPorts);
}} }}
className="w-full rounded border p-2" className="w-full rounded border p-2"
@@ -150,9 +157,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
value={port.publishedPort} value={port.publishedPort}
onChange={(e) => { onChange={(e) => {
const newPorts = [...additionalPorts]; const newPorts = [...additionalPorts];
newPorts[index].publishedPort = Number.parseInt( if (newPorts[index]) {
e.target.value, newPorts[index].publishedPort = Number.parseInt(
); e.target.value,
);
}
setAdditionalPorts(newPorts); setAdditionalPorts(newPorts);
}} }}
className="w-full rounded border p-2" className="w-full rounded border p-2"
@@ -166,11 +175,17 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
value={port.publishMode} value={port.publishMode}
onValueChange={(value: "ingress" | "host") => { onValueChange={(value: "ingress" | "host") => {
const newPorts = [...additionalPorts]; const newPorts = [...additionalPorts];
newPorts[index].publishMode = value;
if (newPorts[index]) {
newPorts[index].publishMode = value;
}
setAdditionalPorts(newPorts); setAdditionalPorts(newPorts);
}} }}
> >
<SelectTrigger id={`publish-mode-${index}`} className="w-full"> <SelectTrigger
id={`publish-mode-${index}`}
className="w-full"
>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

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 }) => {

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

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

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

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