mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
tail: lines.toString(),
|
||||||
|
since,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
|
||||||
let isCurrentConnection = true;
|
if (serverId) {
|
||||||
let noDataTimeout: NodeJS.Timeout;
|
params.append("serverId", serverId);
|
||||||
setIsLoading(true);
|
}
|
||||||
setRawLogs("");
|
|
||||||
setFilteredLogs([]);
|
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const wsUrl = `${protocol}//${
|
||||||
const params = new globalThis.URLSearchParams({
|
window.location.host
|
||||||
containerId,
|
}/docker-container-logs?${params.toString()}`;
|
||||||
tail: lines.toString(),
|
console.log("Connecting to WebSocket:", wsUrl);
|
||||||
since,
|
const ws = new WebSocket(wsUrl);
|
||||||
search,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (serverId) {
|
const resetNoDataTimeout = () => {
|
||||||
params.append("serverId", serverId);
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
}
|
noDataTimeout = setTimeout(() => {
|
||||||
|
if (isCurrentConnection) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, 2000); // Wait 2 seconds for data before showing "No logs found"
|
||||||
|
};
|
||||||
|
|
||||||
const wsUrl = `${protocol}//${
|
ws.onopen = () => {
|
||||||
window.location.host
|
if (!isCurrentConnection) {
|
||||||
}/docker-container-logs?${params.toString()}`;
|
ws.close();
|
||||||
console.log("Connecting to WebSocket:", wsUrl);
|
return;
|
||||||
const ws = new WebSocket(wsUrl);
|
}
|
||||||
|
console.log("WebSocket connected");
|
||||||
|
resetNoDataTimeout();
|
||||||
|
};
|
||||||
|
|
||||||
const resetNoDataTimeout = () => {
|
ws.onmessage = (e) => {
|
||||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
if (!isCurrentConnection) return;
|
||||||
noDataTimeout = setTimeout(() => {
|
setRawLogs((prev) => prev + e.data);
|
||||||
if (isCurrentConnection) {
|
setIsLoading(false);
|
||||||
setIsLoading(false);
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
}
|
};
|
||||||
}, 2000); // Wait 2 seconds for data before showing "No logs found"
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onerror = (error) => {
|
||||||
if (!isCurrentConnection) {
|
if (!isCurrentConnection) return;
|
||||||
ws.close();
|
console.error("WebSocket error:", error);
|
||||||
return;
|
setIsLoading(false);
|
||||||
}
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
console.log("WebSocket connected");
|
};
|
||||||
resetNoDataTimeout();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onclose = (e) => {
|
||||||
if (!isCurrentConnection) return;
|
if (!isCurrentConnection) return;
|
||||||
setRawLogs((prev) => prev + e.data);
|
console.log("WebSocket closed:", e.reason);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
return () => {
|
||||||
if (!isCurrentConnection) return;
|
isCurrentConnection = false;
|
||||||
console.error("WebSocket error:", error);
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
setIsLoading(false);
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
ws.close();
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
}, [containerId, serverId, lines, search, since]);
|
||||||
|
|
||||||
ws.onclose = (e) => {
|
const handleDownload = () => {
|
||||||
if (!isCurrentConnection) return;
|
const logContent = filteredLogs
|
||||||
console.log("WebSocket closed:", e.reason);
|
.map(
|
||||||
setIsLoading(false);
|
({ timestamp, message }: { timestamp: Date | null; message: string }) =>
|
||||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
`${timestamp?.toISOString() || "No timestamp"} ${message}`,
|
||||||
};
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
return () => {
|
const blob = new Blob([logContent], { type: "text/plain" });
|
||||||
isCurrentConnection = false;
|
const url = URL.createObjectURL(blob);
|
||||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
const a = document.createElement("a");
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
const appName = data.Name.replace("/", "") || "app";
|
||||||
ws.close();
|
const isoDate = new Date().toISOString();
|
||||||
}
|
a.href = url;
|
||||||
};
|
a.download = `${appName}-${isoDate.slice(0, 10).replace(/-/g, "")}_${isoDate
|
||||||
}, [containerId, serverId, lines, search, since]);
|
.slice(11, 19)
|
||||||
|
.replace(/:/g, "")}.log.txt`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleFilter = (logs: LogLine[]) => {
|
||||||
const logContent = filteredLogs
|
return logs.filter((log) => {
|
||||||
.map(
|
const logType = getLogType(log.message).type;
|
||||||
({ timestamp, message }: { timestamp: Date | null; message: string }) =>
|
|
||||||
`${timestamp?.toISOString() || "No timestamp"} ${message}`
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const blob = new Blob([logContent], { type: "text/plain" });
|
if (typeFilter.length === 0) {
|
||||||
const url = URL.createObjectURL(blob);
|
return true;
|
||||||
const a = document.createElement("a");
|
}
|
||||||
const appName = data.Name.replace("/", "") || "app";
|
|
||||||
const isoDate = new Date().toISOString();
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${appName}-${isoDate.slice(0, 10).replace(/-/g, "")}_${isoDate
|
|
||||||
.slice(11, 19)
|
|
||||||
.replace(/:/g, "")}.log.txt`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilter = (logs: LogLine[]) => {
|
return typeFilter.includes(logType);
|
||||||
return logs.filter((log) => {
|
});
|
||||||
const logType = getLogType(log.message).type;
|
};
|
||||||
|
|
||||||
const matchesType = typeFilter === "all" || logType === typeFilter;
|
useEffect(() => {
|
||||||
|
setRawLogs("");
|
||||||
|
setFilteredLogs([]);
|
||||||
|
}, [containerId]);
|
||||||
|
|
||||||
return matchesType;
|
useEffect(() => {
|
||||||
});
|
const logs = parseLogs(rawLogs);
|
||||||
};
|
const filtered = handleFilter(logs);
|
||||||
|
setFilteredLogs(filtered);
|
||||||
|
}, [rawLogs, search, lines, since, typeFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRawLogs("");
|
scrollToBottom();
|
||||||
setFilteredLogs([]);
|
|
||||||
}, [containerId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (autoScroll && scrollRef.current) {
|
||||||
const logs = parseLogs(rawLogs);
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
const filtered = handleFilter(logs);
|
}
|
||||||
setFilteredLogs(filtered);
|
}, [filteredLogs, autoScroll]);
|
||||||
}, [rawLogs, search, lines, since, typeFilter]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
scrollToBottom();
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-lg overflow-hidden">
|
||||||
|
<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} />
|
||||||
|
|
||||||
if (autoScroll && scrollRef.current) {
|
<SinceLogsFilter
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
value={since}
|
||||||
}
|
onValueChange={handleSince}
|
||||||
}, [filteredLogs, autoScroll]);
|
showTimestamp={showTimestamp}
|
||||||
|
onTimestampChange={setShowTimestamp}
|
||||||
|
/>
|
||||||
|
|
||||||
return (
|
<StatusLogsFilter
|
||||||
<div className="flex flex-col gap-4">
|
value={typeFilter}
|
||||||
<div className="rounded-lg overflow-hidden">
|
setValue={setTypeFilter}
|
||||||
<div className="space-y-4">
|
title="Log type"
|
||||||
<div className="flex flex-wrap justify-between items-start sm:items-center gap-4">
|
options={priorities}
|
||||||
<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}>
|
<Input
|
||||||
<SelectTrigger className="sm:w-[180px] w-full h-9">
|
type="search"
|
||||||
<SelectValue placeholder="Time filter" />
|
placeholder="Search logs..."
|
||||||
</SelectTrigger>
|
value={search}
|
||||||
<SelectContent>
|
onChange={handleSearch}
|
||||||
<SelectItem value="1h">Last hour</SelectItem>
|
className="inline-flex h-9 text-sm placeholder-gray-400 w-full sm:w-auto"
|
||||||
<SelectItem value="6h">Last 6 hours</SelectItem>
|
/>
|
||||||
<SelectItem value="24h">Last 24 hours</SelectItem>
|
</div>
|
||||||
<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}>
|
<Button
|
||||||
<SelectTrigger className="sm:w-[180px] w-full h-9">
|
variant="outline"
|
||||||
<SelectValue placeholder="Type filter" />
|
size="sm"
|
||||||
</SelectTrigger>
|
className="h-9 sm:w-auto w-full"
|
||||||
<SelectContent>
|
onClick={handleDownload}
|
||||||
<SelectItem value="all">
|
disabled={filteredLogs.length === 0 || !data?.Name}
|
||||||
<Badge variant="blank">All</Badge>
|
>
|
||||||
</SelectItem>
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
<SelectItem value="error">
|
Download logs
|
||||||
<Badge variant="red">Error</Badge>
|
</Button>
|
||||||
</SelectItem>
|
</div>
|
||||||
<SelectItem value="warning">
|
<div
|
||||||
<Badge variant="orange">Warning</Badge>
|
ref={scrollRef}
|
||||||
</SelectItem>
|
onScroll={handleScroll}
|
||||||
<SelectItem value="debug">
|
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||||
<Badge variant="yellow">Debug</Badge>
|
>
|
||||||
</SelectItem>
|
{filteredLogs.length > 0 ? (
|
||||||
<SelectItem value="success">
|
filteredLogs.map((filteredLog: LogLine, index: number) => (
|
||||||
<Badge variant="green">Success</Badge>
|
<TerminalLine
|
||||||
</SelectItem>
|
key={index}
|
||||||
<SelectItem value="info">
|
log={filteredLog}
|
||||||
<Badge variant="blue">Info</Badge>
|
searchTerm={search}
|
||||||
</SelectItem>
|
noTimestamp={!showTimestamp}
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
))
|
||||||
|
) : isLoading ? (
|
||||||
<Input
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||||
type="search"
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
placeholder="Search logs..."
|
</div>
|
||||||
value={search}
|
) : (
|
||||||
onChange={handleSearch}
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||||
className="inline-flex h-9 text-sm placeholder-gray-400 w-full sm:w-auto"
|
No logs found
|
||||||
/>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
<Button
|
</div>
|
||||||
variant="outline"
|
</div>
|
||||||
size="sm"
|
</div>
|
||||||
className="h-9"
|
);
|
||||||
onClick={handleDownload}
|
|
||||||
disabled={filteredLogs.length === 0 || !data?.Name}
|
|
||||||
>
|
|
||||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
|
||||||
Download logs
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#d4d4d4] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
|
||||||
>
|
|
||||||
{filteredLogs.length > 0 ? (
|
|
||||||
filteredLogs.map((filteredLog: LogLine, index: number) => (
|
|
||||||
<TerminalLine
|
|
||||||
key={index}
|
|
||||||
log={filteredLog}
|
|
||||||
searchTerm={search}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
) : isLoading ? (
|
|
||||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
|
||||||
No logs found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h";
|
||||||
|
|
||||||
|
const timeRanges: Array<{ label: string; value: TimeFilter }> = [
|
||||||
|
{
|
||||||
|
label: "All time",
|
||||||
|
value: "all",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last hour",
|
||||||
|
value: "1h",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 6 hours",
|
||||||
|
value: "6h",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 24 hours",
|
||||||
|
value: "24h",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 7 days",
|
||||||
|
value: "168h",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 30 days",
|
||||||
|
value: "720h",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface SinceLogsFilterProps {
|
||||||
|
value: TimeFilter;
|
||||||
|
onValueChange: (value: TimeFilter) => void;
|
||||||
|
showTimestamp: boolean;
|
||||||
|
onTimestampChange: (show: boolean) => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SinceLogsFilter({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
showTimestamp,
|
||||||
|
onTimestampChange,
|
||||||
|
title = "Time range",
|
||||||
|
}: SinceLogsFilterProps) {
|
||||||
|
const selectedLabel =
|
||||||
|
timeRanges.find((range) => range.value === value)?.label ??
|
||||||
|
"Select time range";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
<div className="space-x-1 flex">
|
||||||
|
<Badge variant="blank" className="rounded-sm px-1 font-normal">
|
||||||
|
{selectedLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup>
|
||||||
|
{timeRanges.map((range) => {
|
||||||
|
const isSelected = value === range.value;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={range.value}
|
||||||
|
onSelect={() => {
|
||||||
|
if (!isSelected) {
|
||||||
|
onValueChange(range.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className={cn("h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">{range.label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<div className="p-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm">Show timestamps</span>
|
||||||
|
<Switch checked={showTimestamp} onCheckedChange={onTimestampChange} />
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
interface StatusLogsFilterProps {
|
||||||
|
value?: string[];
|
||||||
|
setValue?: (value: string[]) => void;
|
||||||
|
title?: string;
|
||||||
|
options: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusLogsFilter({
|
||||||
|
value = [],
|
||||||
|
setValue,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
}: StatusLogsFilterProps) {
|
||||||
|
const selectedValues = new Set(value as string[]);
|
||||||
|
const allSelected = selectedValues.size === 0;
|
||||||
|
|
||||||
|
const getSelectedBadges = () => {
|
||||||
|
if (allSelected) {
|
||||||
|
return (
|
||||||
|
<Badge variant="blank" className="rounded-sm px-1 font-normal">
|
||||||
|
All
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedValues.size >= 1) {
|
||||||
|
const selected = options.find((opt) => selectedValues.has(opt.value));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
selected?.value === "success"
|
||||||
|
? "green"
|
||||||
|
: selected?.value === "error"
|
||||||
|
? "red"
|
||||||
|
: selected?.value === "warning"
|
||||||
|
? "orange"
|
||||||
|
: selected?.value === "info"
|
||||||
|
? "blue"
|
||||||
|
: selected?.value === "debug"
|
||||||
|
? "yellow"
|
||||||
|
: "blank"
|
||||||
|
}
|
||||||
|
className="rounded-sm px-1 font-normal"
|
||||||
|
>
|
||||||
|
{selected?.label}
|
||||||
|
</Badge>
|
||||||
|
{selectedValues.size > 1 && (
|
||||||
|
<Badge variant="blank" className="rounded-sm px-1 font-normal">
|
||||||
|
+{selectedValues.size - 1}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
<div className="space-x-1 flex">{getSelectedBadges()}</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
setValue?.([]); // Empty array means "All"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
|
||||||
|
allSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className={cn("h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
<Badge variant="blank">All</Badge>
|
||||||
|
</CommandItem>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedValues.has(option.value);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
onSelect={() => {
|
||||||
|
const newValues = new Set(selectedValues);
|
||||||
|
if (isSelected) {
|
||||||
|
newValues.delete(option.value);
|
||||||
|
} else {
|
||||||
|
newValues.add(option.value);
|
||||||
|
}
|
||||||
|
setValue?.(Array.from(newValues));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className={cn("h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
{option.icon && (
|
||||||
|
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
option.value === "success"
|
||||||
|
? "green"
|
||||||
|
: option.value === "error"
|
||||||
|
? "red"
|
||||||
|
: option.value === "warning"
|
||||||
|
? "orange"
|
||||||
|
: option.value === "info"
|
||||||
|
? "blue"
|
||||||
|
: option.value === "debug"
|
||||||
|
? "yellow"
|
||||||
|
: "blank"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Badge>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -6,13 +6,144 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ShieldCheck } from "lucide-react";
|
import { AlertCircle, Link, ShieldCheck } from "lucide-react";
|
||||||
import { AddCertificate } from "./add-certificate";
|
import { AddCertificate } from "./add-certificate";
|
||||||
import { DeleteCertificate } from "./delete-certificate";
|
import { DeleteCertificate } from "./delete-certificate";
|
||||||
|
|
||||||
export const ShowCertificates = () => {
|
export const ShowCertificates = () => {
|
||||||
const { data } = api.certificates.all.useQuery();
|
const { data } = api.certificates.all.useQuery();
|
||||||
|
|
||||||
|
const extractExpirationDate = (certData: string): Date | null => {
|
||||||
|
try {
|
||||||
|
const match = certData.match(
|
||||||
|
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/,
|
||||||
|
);
|
||||||
|
if (!match?.[1]) return null;
|
||||||
|
|
||||||
|
const base64Cert = match[1].replace(/\s/g, "");
|
||||||
|
const binaryStr = window.atob(base64Cert);
|
||||||
|
const bytes = new Uint8Array(binaryStr.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateFound = 0;
|
||||||
|
for (let i = 0; i < bytes.length - 2; i++) {
|
||||||
|
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
|
||||||
|
const dateType = bytes[i];
|
||||||
|
const dateLength = bytes[i + 1];
|
||||||
|
if (typeof dateLength === "undefined") continue;
|
||||||
|
|
||||||
|
if (dateFound === 0) {
|
||||||
|
dateFound++;
|
||||||
|
i += dateLength + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateStr = "";
|
||||||
|
for (let j = 0; j < dateLength; j++) {
|
||||||
|
const charCode = bytes[i + 2 + j];
|
||||||
|
if (typeof charCode === "undefined") continue;
|
||||||
|
dateStr += String.fromCharCode(charCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateType === 0x17) {
|
||||||
|
// UTCTime (YYMMDDhhmmssZ)
|
||||||
|
const year = Number.parseInt(dateStr.slice(0, 2));
|
||||||
|
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(
|
||||||
|
fullYear,
|
||||||
|
Number.parseInt(dateStr.slice(2, 4)) - 1,
|
||||||
|
Number.parseInt(dateStr.slice(4, 6)),
|
||||||
|
Number.parseInt(dateStr.slice(6, 8)),
|
||||||
|
Number.parseInt(dateStr.slice(8, 10)),
|
||||||
|
Number.parseInt(dateStr.slice(10, 12)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneralizedTime (YYYYMMDDhhmmssZ)
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(
|
||||||
|
Number.parseInt(dateStr.slice(0, 4)),
|
||||||
|
Number.parseInt(dateStr.slice(4, 6)) - 1,
|
||||||
|
Number.parseInt(dateStr.slice(6, 8)),
|
||||||
|
Number.parseInt(dateStr.slice(8, 10)),
|
||||||
|
Number.parseInt(dateStr.slice(10, 12)),
|
||||||
|
Number.parseInt(dateStr.slice(12, 14)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing certificate:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExpirationStatus = (certData: string) => {
|
||||||
|
const expirationDate = extractExpirationDate(certData);
|
||||||
|
|
||||||
|
if (!expirationDate)
|
||||||
|
return {
|
||||||
|
status: "unknown" as const,
|
||||||
|
className: "text-muted-foreground",
|
||||||
|
message: "Could not determine expiration",
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const daysUntilExpiration = Math.ceil(
|
||||||
|
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (daysUntilExpiration < 0) {
|
||||||
|
return {
|
||||||
|
status: "expired" as const,
|
||||||
|
className: "text-red-500",
|
||||||
|
message: `Expired on ${expirationDate.toLocaleDateString([], {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysUntilExpiration <= 30) {
|
||||||
|
return {
|
||||||
|
status: "warning" as const,
|
||||||
|
className: "text-yellow-500",
|
||||||
|
message: `Expires in ${daysUntilExpiration} days`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "valid" as const,
|
||||||
|
className: "text-muted-foreground",
|
||||||
|
message: `Expires ${expirationDate.toLocaleDateString([], {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCertificateChainInfo = (certData: string) => {
|
||||||
|
const certCount = (certData.match(/-----BEGIN CERTIFICATE-----/g) || [])
|
||||||
|
.length;
|
||||||
|
return certCount > 1
|
||||||
|
? {
|
||||||
|
isChain: true,
|
||||||
|
count: certCount,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
isChain: false,
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<Card className="bg-transparent h-full">
|
<Card className="bg-transparent h-full">
|
||||||
@@ -23,7 +154,7 @@ export const ShowCertificates = () => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 pt-4 h-full">
|
<CardContent className="space-y-2 pt-4 h-full">
|
||||||
{data?.length === 0 ? (
|
{!data?.length ? (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<ShieldCheck className="size-8 self-center text-muted-foreground" />
|
<ShieldCheck className="size-8 self-center text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
@@ -35,21 +166,53 @@ export const ShowCertificates = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
{data?.map((destination, index) => (
|
{data.map((certificate, index) => {
|
||||||
<div
|
const expiration = getExpirationStatus(
|
||||||
key={destination.certificateId}
|
certificate.certificateData,
|
||||||
className="flex items-center justify-between border p-4 rounded-lg"
|
);
|
||||||
>
|
const chainInfo = getCertificateChainInfo(
|
||||||
<span className="text-sm text-muted-foreground">
|
certificate.certificateData,
|
||||||
{index + 1}. {destination.name}
|
);
|
||||||
</span>
|
return (
|
||||||
<div className="flex flex-row gap-3">
|
<div
|
||||||
<DeleteCertificate
|
key={certificate.certificateId}
|
||||||
certificateId={destination.certificateId}
|
className="flex flex-col border p-4 rounded-lg space-y-2"
|
||||||
/>
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{index + 1}. {certificate.name}
|
||||||
|
</span>
|
||||||
|
{chainInfo.isChain && (
|
||||||
|
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
|
||||||
|
<Link className="size-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Chain ({chainInfo.count})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DeleteCertificate
|
||||||
|
certificateId={certificate.certificateId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-xs flex items-center gap-1.5 ${expiration.className}`}
|
||||||
|
>
|
||||||
|
{expiration.status !== "valid" && (
|
||||||
|
<AlertCircle className="size-3" />
|
||||||
|
)}
|
||||||
|
{expiration.message}
|
||||||
|
{certificate.autoRenew &&
|
||||||
|
expiration.status !== "valid" && (
|
||||||
|
<span className="text-xs text-emerald-500 ml-1">
|
||||||
|
(Auto-renewal enabled)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<AddCertificate />
|
<AddCertificate />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -33,7 +34,13 @@ export const ShowNodeData = ({ data }: Props) => {
|
|||||||
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card">
|
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card">
|
||||||
<code>
|
<code>
|
||||||
<pre className="whitespace-pre-wrap break-words">
|
<pre className="whitespace-pre-wrap break-words">
|
||||||
{JSON.stringify(data, null, 2)}
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
lineWrapping
|
||||||
|
lineNumbers={false}
|
||||||
|
readOnly
|
||||||
|
value={JSON.stringify(data, null, 2)}
|
||||||
|
/>
|
||||||
</pre>
|
</pre>
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -24,8 +24,13 @@ export const DeleteNotification = ({ notificationId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button
|
||||||
<TrashIcon className="size-4 text-muted-foreground" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 group hover:bg-red-500/10"
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-muted-foreground group-hover:text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -40,48 +40,58 @@ export const ShowNotifications = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid lg:grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
{data?.map((notification, index) => (
|
{data?.map((notification, index) => (
|
||||||
<div
|
<div
|
||||||
key={notification.notificationId}
|
key={notification.notificationId}
|
||||||
className="flex items-center justify-between border gap-2 p-3.5 rounded-lg"
|
className="flex items-center justify-between rounded-xl p-4 transition-colors dark:bg-zinc-900/50 hover:bg-zinc-900 border border-zinc-800/50"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row gap-2 items-center w-full ">
|
<div className="flex items-center gap-4">
|
||||||
{notification.notificationType === "slack" && (
|
{notification.notificationType === "slack" && (
|
||||||
<SlackIcon className="text-muted-foreground size-6 flex-shrink-0" />
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
|
||||||
)}
|
<SlackIcon className="h-6 w-6 text-indigo-400" />
|
||||||
{notification.notificationType === "telegram" && (
|
</div>
|
||||||
<TelegramIcon className="text-muted-foreground size-8 flex-shrink-0" />
|
)}
|
||||||
)}
|
{notification.notificationType === "telegram" && (
|
||||||
{notification.notificationType === "discord" && (
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-500/10">
|
||||||
<DiscordIcon className="text-muted-foreground size-7 flex-shrink-0" />
|
<TelegramIcon className="h-6 w-6 text-indigo-400" />
|
||||||
)}
|
</div>
|
||||||
{notification.notificationType === "email" && (
|
)}
|
||||||
<Mail
|
{notification.notificationType === "discord" && (
|
||||||
size={29}
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
|
||||||
className="text-muted-foreground size-6 flex-shrink-0"
|
<DiscordIcon className="h-6 w-6 text-indigo-400" />
|
||||||
/>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="text-sm text-muted-foreground">
|
{notification.notificationType === "email" && (
|
||||||
{notification.name}
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-500/10">
|
||||||
</span>
|
<Mail className="h-6 w-6 text-indigo-400" />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex flex-row gap-1 w-fit">
|
<div className="flex flex-col">
|
||||||
<UpdateNotification
|
<span className="text-sm font-medium text-zinc-300">
|
||||||
notificationId={notification.notificationId}
|
{notification.name}
|
||||||
/>
|
</span>
|
||||||
<DeleteNotification
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
notificationId={notification.notificationId}
|
{notification.notificationType?.[0]?.toUpperCase() + notification.notificationType?.slice(1)} notification
|
||||||
/>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<UpdateNotification
|
||||||
<div className="flex flex-col gap-4 justify-end w-full items-end">
|
notificationId={notification.notificationId}
|
||||||
<AddNotification />
|
/>
|
||||||
|
<DeleteNotification
|
||||||
|
notificationId={notification.notificationId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 justify-end w-full items-end">
|
||||||
|
<AddNotification />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Mail, PenBoxIcon } from "lucide-react";
|
import { Mail, Pen } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { FieldErrors, useFieldArray, useForm } from "react-hook-form";
|
import { FieldErrors, useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -218,8 +218,10 @@ export const UpdateNotification = ({ notificationId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger className="" asChild>
|
<DialogTrigger className="" asChild>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost"
|
||||||
<PenBoxIcon className="size-4 text-muted-foreground" />
|
size="icon"
|
||||||
|
className="h-9 w-9">
|
||||||
|
<Pen className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const ToggleAutoCheckUpdates = ({ disabled }: { disabled: boolean }) => {
|
||||||
|
const [enabled, setEnabled] = useState<boolean>(
|
||||||
|
localStorage.getItem("enableAutoCheckUpdates") === "true",
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggle = (checked: boolean) => {
|
||||||
|
setEnabled(checked);
|
||||||
|
localStorage.setItem("enableAutoCheckUpdates", String(checked));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
id="autoCheckUpdatesToggle"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Label className="text-primary" htmlFor="autoCheckUpdatesToggle">
|
||||||
|
Automatically check for new updates
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,91 +3,224 @@ import { Button } from "@/components/ui/button";
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { RefreshCcw } from "lucide-react";
|
import {
|
||||||
|
Bug,
|
||||||
|
Download,
|
||||||
|
Info,
|
||||||
|
RefreshCcw,
|
||||||
|
Server,
|
||||||
|
ServerCrash,
|
||||||
|
Sparkles,
|
||||||
|
Stars,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates";
|
||||||
import { UpdateWebServer } from "./update-webserver";
|
import { UpdateWebServer } from "./update-webserver";
|
||||||
|
|
||||||
export const UpdateServer = () => {
|
export const UpdateServer = () => {
|
||||||
const [isUpdateAvailable, setIsUpdateAvailable] = useState<null | boolean>(
|
const [hasCheckedUpdate, setHasCheckedUpdate] = useState(false);
|
||||||
null,
|
const [isUpdateAvailable, setIsUpdateAvailable] = useState(false);
|
||||||
);
|
const { mutateAsync: getUpdateData, isLoading } =
|
||||||
const { mutateAsync: checkAndUpdateImage, isLoading } =
|
api.settings.getUpdateData.useMutation();
|
||||||
api.settings.checkAndUpdateImage.useMutation();
|
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||||
|
const { data: releaseTag } = api.settings.getReleaseTag.useQuery();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [latestVersion, setLatestVersion] = useState("");
|
||||||
|
|
||||||
|
const handleCheckUpdates = async () => {
|
||||||
|
try {
|
||||||
|
const updateData = await getUpdateData();
|
||||||
|
const versionToUpdate = updateData.latestVersion || "";
|
||||||
|
setHasCheckedUpdate(true);
|
||||||
|
setIsUpdateAvailable(updateData.updateAvailable);
|
||||||
|
setLatestVersion(versionToUpdate);
|
||||||
|
|
||||||
|
if (updateData.updateAvailable) {
|
||||||
|
toast.success(versionToUpdate, {
|
||||||
|
description: "New version available!",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.info("No updates available");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking for updates:", error);
|
||||||
|
setHasCheckedUpdate(true);
|
||||||
|
setIsUpdateAvailable(false);
|
||||||
|
toast.error(
|
||||||
|
"An error occurred while checking for updates, please try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="secondary">
|
<Button variant="secondary" className="gap-2">
|
||||||
<RefreshCcw className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
Updates
|
Updates
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:m:max-w-lg ">
|
<DialogContent className="max-w-lg p-6">
|
||||||
<DialogHeader>
|
<div className="flex items-center justify-between mb-8">
|
||||||
<DialogTitle>Web Server Update</DialogTitle>
|
<DialogTitle className="text-2xl font-semibold">
|
||||||
<DialogDescription>
|
Web Server Update
|
||||||
Check new releases and update your dokploy
|
</DialogTitle>
|
||||||
</DialogDescription>
|
{dokployVersion && (
|
||||||
</DialogHeader>
|
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
|
||||||
|
<Server className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{dokployVersion} | {releaseTag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
{/* Initial state */}
|
||||||
<span className="text-sm text-muted-foreground">
|
{!hasCheckedUpdate && (
|
||||||
We suggest to update your dokploy to the latest version only if you:
|
<div className="mb-8">
|
||||||
</span>
|
<p className="text text-muted-foreground">
|
||||||
<ul className="list-disc list-inside text-sm text-muted-foreground">
|
Check for new releases and update Dokploy.
|
||||||
<li>Want to try the latest features</li>
|
<br />
|
||||||
<li>Some bug that is blocking to use some features</li>
|
<br />
|
||||||
</ul>
|
We recommend checking for updates regularly to ensure you have the
|
||||||
<AlertBlock type="info">
|
latest features and security improvements.
|
||||||
We recommend checking the latest version for any breaking changes
|
</p>
|
||||||
before updating. Go to{" "}
|
</div>
|
||||||
<Link
|
)}
|
||||||
href="https://github.com/Dokploy/dokploy/releases"
|
|
||||||
target="_blank"
|
|
||||||
className="text-foreground"
|
|
||||||
>
|
|
||||||
Dokploy Releases
|
|
||||||
</Link>{" "}
|
|
||||||
to check the latest version.
|
|
||||||
</AlertBlock>
|
|
||||||
|
|
||||||
<div className="w-full flex flex-col gap-4">
|
{/* Update available state */}
|
||||||
{isUpdateAvailable === false && (
|
{isUpdateAvailable && latestVersion && (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="mb-8">
|
||||||
<RefreshCcw className="size-6 self-center text-muted-foreground" />
|
<div className="inline-flex items-center gap-2 rounded-lg px-3 py-2 border border-emerald-900 bg-emerald-900 dark:bg-emerald-900/40 mb-4 w-full">
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="flex items-center gap-1.5">
|
||||||
You are using the latest version
|
<span className="flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-2 w-2 rounded-full bg-emerald-400 opacity-75" />
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500" />
|
||||||
|
</span>
|
||||||
|
<Download className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span className="text font-medium text-emerald-400 ">
|
||||||
|
New version available:
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<span className="text font-semibold text-emerald-300">
|
||||||
|
{latestVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 text-muted-foreground">
|
||||||
|
<p className="text">
|
||||||
|
A new version of the server software is available. Consider
|
||||||
|
updating if you:
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Stars className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
|
||||||
|
<span className="text">
|
||||||
|
Want to access the latest features and improvements
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<Bug className="h-5 w-5 mt-0.5 text-[#5B9DFF]" />
|
||||||
|
<span className="text">
|
||||||
|
Are experiencing issues that may be resolved in the new
|
||||||
|
version
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Up to date state */}
|
||||||
|
{hasCheckedUpdate && !isUpdateAvailable && !isLoading && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex flex-col items-center gap-6 mb-6">
|
||||||
|
<div className="rounded-full p-4 bg-emerald-400/40">
|
||||||
|
<Sparkles className="h-8 w-8 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-medium">
|
||||||
|
You are using the latest version
|
||||||
|
</h3>
|
||||||
|
<p className="text text-muted-foreground">
|
||||||
|
Your server is up to date with all the latest features and
|
||||||
|
security improvements.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasCheckedUpdate && isLoading && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex flex-col items-center gap-6 mb-6">
|
||||||
|
<div className="rounded-full p-4 bg-[#5B9DFF]/40 text-foreground">
|
||||||
|
<RefreshCcw className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<h3 className="text-lg font-medium">Checking for updates...</h3>
|
||||||
|
<p className="text text-muted-foreground">
|
||||||
|
Please wait while we pull the latest version information from
|
||||||
|
Docker Hub.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isUpdateAvailable && (
|
||||||
|
<div className="rounded-lg bg-[#16254D] p-4 mb-8">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Info className="h-5 w-5 flex-shrink-0 text-[#5B9DFF]" />
|
||||||
|
<div className="text-[#5B9DFF]">
|
||||||
|
We recommend reviewing the{" "}
|
||||||
|
<Link
|
||||||
|
href="https://github.com/Dokploy/dokploy/releases"
|
||||||
|
target="_blank"
|
||||||
|
className="text-white underline hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
release notes
|
||||||
|
</Link>{" "}
|
||||||
|
for any breaking changes before updating.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<ToggleAutoCheckUpdates disabled={isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 flex items-center justify-end">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
{isUpdateAvailable ? (
|
{isUpdateAvailable ? (
|
||||||
<UpdateWebServer />
|
<UpdateWebServer />
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
variant="secondary"
|
||||||
onClick={async () => {
|
onClick={handleCheckUpdates}
|
||||||
await checkAndUpdateImage()
|
disabled={isLoading}
|
||||||
.then(async (e) => {
|
|
||||||
setIsUpdateAvailable(e);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setIsUpdateAvailable(false);
|
|
||||||
toast.error("Error to check updates");
|
|
||||||
});
|
|
||||||
toast.success("Check updates");
|
|
||||||
}}
|
|
||||||
isLoading={isLoading}
|
|
||||||
>
|
>
|
||||||
Check Updates
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCcw className="h-4 w-4 animate-spin" />
|
||||||
|
Checking for updates
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
Check for updates
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -96,3 +229,5 @@ export const UpdateServer = () => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default UpdateServer;
|
||||||
|
|||||||
@@ -11,24 +11,53 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { HardDriveDownload } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const UpdateWebServer = () => {
|
interface Props {
|
||||||
|
isNavbar?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateWebServer = ({ isNavbar }: Props) => {
|
||||||
const { mutateAsync: updateServer, isLoading } =
|
const { mutateAsync: updateServer, isLoading } =
|
||||||
api.settings.updateServer.useMutation();
|
api.settings.updateServer.useMutation();
|
||||||
|
|
||||||
|
const buttonLabel = isNavbar ? "Update available" : "Update Server";
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
try {
|
||||||
|
await updateServer();
|
||||||
|
toast.success(
|
||||||
|
"The server has been updated. The page will be reloaded to reflect the changes...",
|
||||||
|
);
|
||||||
|
setTimeout(() => {
|
||||||
|
// Allow seeing the toast before reloading
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating server:", error);
|
||||||
|
toast.error(
|
||||||
|
"An error occurred while updating the server, please try again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
variant="secondary"
|
variant={isNavbar ? "outline" : "secondary"}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
>
|
>
|
||||||
<span className="absolute -right-1 -top-2 flex h-3 w-3">
|
{!isLoading && <HardDriveDownload className="h-4 w-4" />}
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
{!isLoading && (
|
||||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
|
<span className="absolute -right-1 -top-2 flex h-3 w-3">
|
||||||
</span>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
|
||||||
Update Server
|
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLoading ? "Updating..." : buttonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -36,19 +65,12 @@ export const UpdateWebServer = () => {
|
|||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This action cannot be undone. This will update the web server to the
|
This action cannot be undone. This will update the web server to the
|
||||||
new version.
|
new version. The page will be reloaded once the update is finished.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction onClick={handleConfirm}>Confirm</AlertDialogAction>
|
||||||
onClick={async () => {
|
|
||||||
await updateServer();
|
|
||||||
toast.success("Please reload the browser to see the changes");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
BIN
apps/dokploy/public/templates/onedev.png
Normal file
BIN
apps/dokploy/public/templates/onedev.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -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;
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
12
apps/dokploy/templates/onedev/docker-compose.yml
Normal file
12
apps/dokploy/templates/onedev/docker-compose.yml
Normal 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:
|
||||||
22
apps/dokploy/templates/onedev/index.ts
Normal file
22
apps/dokploy/templates/onedev/index.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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})`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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})`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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}\`\`\``,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}\`\`\``,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user