Compare commits

...

88 Commits

Author SHA1 Message Date
Mauricio Siu
839e1c0f9f Merge pull request #961 from Dokploy/canary
v0.15.1
2024-12-21 15:42:19 -06:00
Mauricio Siu
54dd531a26 Merge branch 'main' into canary 2024-12-21 15:36:20 -06:00
Mauricio Siu
7ebf5ad0f9 chore: bump version 2024-12-21 15:28:27 -06:00
Mauricio Siu
b85163d935 refactor: add conditional value 2024-12-21 15:10:00 -06:00
Mauricio Siu
a953e59327 Merge pull request #960 from Dokploy/fix/add-latest-cases
refactor: update digest
2024-12-21 15:00:51 -06:00
Mauricio Siu
b2661e4533 refactor: update 2024-12-21 14:58:58 -06:00
Mauricio Siu
883459624e refactor: update digest 2024-12-21 14:54:14 -06:00
Mauricio Siu
6e2b2d564b refactor: add missing additional ports 2024-12-21 14:11:06 -06:00
Mauricio Siu
065963857c Merge pull request #958 from Dokploy/fix/update-fetch
Fix/update fetch
2024-12-21 13:50:13 -06:00
Mauricio Siu
a0c9df4bd4 refactor: add 7 minutes interval 2024-12-21 13:35:17 -06:00
Mauricio Siu
68c8c70260 refactor: iterate pages 2024-12-21 13:34:50 -06:00
Mauricio Siu
a926f28d30 Merge pull request #930 from lost-end-found/additional-ports-management
feat(ports): implement additional ports management
2024-12-21 13:30:32 -06:00
Mauricio Siu
59c0636fb0 refactor: add is cloud to fn 2024-12-21 13:19:30 -06:00
Mauricio Siu
ae159c5678 refactor: add tag to web server update 2024-12-21 13:13:38 -06:00
Mauricio Siu
e42e9bec17 refactor: use schema inline 2024-12-21 12:48:32 -06:00
Mauricio Siu
978324e2bf Merge pull request #957 from drudge/check-for-updates
New Updates UI
2024-12-21 12:47:24 -06:00
Nicholas Penree
8f05f06259 Merge remote-tracking branch 'dokploy/canary' into check-for-updates 2024-12-21 13:47:01 -05:00
Mauricio Siu
392be2cfa2 Merge pull request #951 from szwabodev/checkUpdatesTweaks
feat: automatic check for updates
2024-12-21 12:45:39 -06:00
Mauricio Siu
18e89df9a5 Update apps/dokploy/components/layouts/navbar.tsx 2024-12-21 12:45:14 -06:00
UndefinedPony
4d2a9f8aa7 refactor: use dynamic tag for comparing latest tag digest 2024-12-21 13:35:39 -05:00
Nicholas Penree
d08530d451 feat(updates): clean up light mode 2024-12-21 13:02:58 -05:00
UndefinedPony
6c9b12cee9 refactor: use dynamic tag for comparing latest tag digest 2024-12-21 18:33:22 +01:00
Nicholas Penree
a8ff6c7b3f feat(updates): new update UI 2024-12-21 11:24:19 -05:00
UndefinedPony
8699e024ee refactor: add try catch, add default update data 2024-12-21 10:05:31 +01:00
Mauricio Siu
73782ffd26 Merge pull request #954 from Dokploy/915-daily-docker-cleanup-seems-to-be-doing-nothing
fix: add missing notifications in cron jobs
2024-12-21 02:51:52 -06:00
Mauricio Siu
7a8bb8f71d fix: add missing notifications in cron jobs 2024-12-21 02:45:58 -06:00
UndefinedPony
18eae9f7d7 refactor: use service image sha instead of image itself for checking updates 2024-12-21 09:04:25 +01:00
Mauricio Siu
1aae523a0b refactor: add missing verifyToken 2024-12-21 01:53:39 -06:00
UndefinedPony
f40e802331 fix: pull latest release in case of no image when checking update 2024-12-21 08:47:21 +01:00
Mauricio Siu
d979aa17c2 Merge pull request #948 from 190km/notifications-style
style(notifications): better notification item style
2024-12-20 23:34:57 -06:00
Mauricio Siu
e2d20fb0e3 Merge pull request #929 from drudge/certificate-details
feat(certs): show expiration and chain details
2024-12-20 23:23:33 -06:00
Mauricio Siu
62f59c1f9a Merge pull request #941 from mezotv/bump-plausible
feat(plausible): bump to 2.1.4
2024-12-20 23:17:22 -06:00
Mauricio Siu
93e1071057 Merge pull request #949 from 190km/fix-edit-password
fix(settings/profile): edit profile password fixed
2024-12-20 23:17:07 -06:00
Mauricio Siu
788771c5eb refactor: add password in validation 2024-12-20 23:16:38 -06:00
UndefinedPony
ab9aa56c48 refactor: disable automatic updates for cloud version 2024-12-20 18:57:28 +01:00
UndefinedPony
4565b3d7a2 refactor: add latestVersion information to update data 2024-12-20 18:26:54 +01:00
UndefinedPony
c8514e3a1b refactor: remove unused async 2024-12-20 17:32:10 +01:00
UndefinedPony
a06dd17aa1 feat(navbar): add automatic update checking interval, add update available button 2024-12-20 17:30:14 +01:00
UndefinedPony
256534570b refactor: add image tag helper, refactor update check logic, remove try/catch 2024-12-20 17:29:01 +01:00
UndefinedPony
2804748118 refactor: rename action, move pull to updateServer 2024-12-20 17:27:51 +01:00
UndefinedPony
e6bc40e7fe refactor: adapt to navbar version, move confirm action, add reload info 2024-12-20 17:27:14 +01:00
UndefinedPony
196603126b refactor: move check updates function, use new api 2024-12-20 17:26:31 +01:00
UndefinedPony
a5cd8f18cd feat: show auto check update toggle 2024-12-20 17:23:02 +01:00
UndefinedPony
b842887bc3 feat: add toggle for auto updates checking 2024-12-20 16:43:05 +01:00
UndefinedPony
dd64b06340 style: format with biome 2024-12-20 14:09:05 +01:00
UndefinedPony
d9a1976cc0 fix: check updates message fixes 2024-12-20 14:01:55 +01:00
190km
fdfa927532 feat(settings/profile): reset password form after validating password change 2024-12-20 01:00:16 +01:00
190km
bf2551b0f6 fix(settings/profile): fixed password changing 2024-12-20 00:54:32 +01:00
usopp
ed8be62ff3 chore: lint 2024-12-20 00:24:44 +01:00
usopp
77336a21f9 chore: lint 2024-12-20 00:21:51 +01:00
usopp
e05d01788f style(notifications): better notification item style 2024-12-20 00:11:48 +01:00
Dominik Koch
651e81ce6d feat(plausible): bump to 2.1.4 2024-12-19 11:22:58 +01:00
Mauricio Siu
fac29b70a5 Merge pull request #937 from drudge/fix-custom-registry
fix(docker): fix for custom registry login
2024-12-19 02:14:34 -06:00
Mauricio Siu
95eaab43df Merge pull request #936 from drudge/fix-light-term
fix(term): fix light mode foreground color
2024-12-19 02:14:26 -06:00
Mauricio Siu
abdef13b93 refactor: set current color 2024-12-19 02:14:06 -06:00
Mauricio Siu
65f397e1b1 Merge pull request #939 from 190km/2fa-typo-login
fix(2fa-login): typo - Setup -> Login
2024-12-19 02:08:26 -06:00
Mauricio Siu
1ae96297e8 refactor: update lint 2024-12-19 02:07:08 -06:00
Mauricio Siu
c51b502116 refactor: add path join to prevent concatenate double slash and update the getImageName 2024-12-19 02:05:30 -06:00
Mauricio Siu
5a42b78098 Merge pull request #940 from drudge/wrap-secrets
fix(preview-deployments): wrap long envs
2024-12-19 01:19:01 -06:00
Nicholas Penree
b39c0ef915 fix(preview-deployments): wrap long envs 2024-12-18 23:00:50 -05:00
Nicholas Penree
844d582147 fix(docker): fix for custom registry login 2024-12-18 21:58:22 -05:00
190km
0b51088489 fix: typo - Setup -> Login 2024-12-19 02:10:07 +01:00
Nicholas Penree
1dece58cff fix(term): fix light mode foreground color
closes #907
2024-12-18 13:56:09 -05:00
Larry Ioannidis
d22330f983 feat(ports): implement additional ports management 2024-12-18 09:39:20 +00:00
Mauricio Siu
852895c382 Merge pull request #912 from drudge/new-ansi-logs
feat(logs): support ansi codes
2024-12-17 23:55:12 -06:00
Mauricio Siu
20d5913820 Merge pull request #910 from drudge/canary
feat(cluster): use code editor for node config
2024-12-17 23:52:25 -06:00
Mauricio Siu
f1b4a73158 Merge pull request #928 from drudge/discord-notif
feat(discord): remove dots
2024-12-17 23:51:01 -06:00
Mauricio Siu
3830f6c4ee Merge pull request #925 from mohabgabber/canary
Added onedev docker compose
2024-12-17 23:50:33 -06:00
Mauricio Siu
5c8eda2405 Update apps/dokploy/templates/onedev/docker-compose.yml 2024-12-17 23:47:48 -06:00
Mauricio Siu
6bf85bcfa3 Merge branch 'canary' into new-ansi-logs 2024-12-17 23:43:18 -06:00
Mauricio Siu
bc03e718bf Merge pull request #911 from 190km/mutlti-select-fiter-logs
feat(logs): multi select fiter logs & hide/show timestamp
2024-12-17 23:42:50 -06:00
Nicholas Penree
a941efb1ff feat(certs): show expiration and chain details 2024-12-17 23:17:29 -05:00
Nicholas Penree
fe2de6b899 feat(discord): remove dots 2024-12-17 22:03:58 -05:00
usopp
b3313cf975 style: better white style 2024-12-17 19:16:40 +01:00
Mohab Gabber
4e31d8ac02 Added onedev to templates.ts and onedev's icon 2024-12-17 18:51:38 +02:00
Mohab Gabber
536507377d Added onedev docker compose 2024-12-17 17:23:26 +02:00
Nicholas Penree
6db9c99080 feat(logs): add number of lines filter 2024-12-16 20:12:05 -05:00
Nicholas Penree
7e8953ff44 chore: lint 2024-12-16 17:18:11 -05:00
Nicholas Penree
81c85ce155 fix: don't trigger if already selected 2024-12-16 17:12:18 -05:00
Nicholas Penree
bd16e03602 chore: lint 2024-12-16 17:07:31 -05:00
190km
87a5ce2053 fix: timestamp width 2024-12-16 22:55:36 +01:00
Nicholas Penree
ca4820940e feat(logs): filter improvements 2024-12-16 16:55:13 -05:00
190km
71fe6de9cb feat(logs): added show/hide timestamp option 2024-12-16 21:27:32 +01:00
Nicholas Penree
9ff4968e61 feat(logs): support ansi codes 2024-12-16 14:59:35 -05:00
190km
2312ae1c12 fix: fixed lint 2024-12-16 19:56:47 +01:00
190km
b03011a94f feat(logs): replaced the log type component with the new 2024-12-16 19:50:13 +01:00
190km
7577e40b25 feat(logs): added filter log type component 2024-12-16 19:49:09 +01:00
Nicholas Penree
75e34285ef feat(cluster): use code editor for node config 2024-12-16 11:51:17 -05:00
44 changed files with 2114 additions and 548 deletions

View File

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

View File

@@ -111,7 +111,7 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
<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"
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
> {
filteredLogs.length > 0 ? filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine

View File

@@ -117,7 +117,7 @@ export const ShowDeploymentCompose = ({
<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"
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ import {
import { cn } from "@/lib/utils";
import { escapeRegExp } from "lodash";
import React from "react";
import { type LogLine, getLogType } from "./utils";
import { type LogLine, getLogType, parseAnsi } from "./utils";
interface LogLineProps {
log: LogLine;
@@ -33,18 +33,38 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
: "--- No time found ---";
const highlightMessage = (text: string, term: string) => {
if (!term) return text;
const parts = text.split(new RegExp(`(${escapeRegExp(term)})`, "gi"));
return parts.map((part, index) =>
part.toLowerCase() === term.toLowerCase() ? (
<span key={index} className="bg-yellow-200 dark:bg-yellow-900">
{part}
if (!term) {
const segments = parseAnsi(text);
return segments.map((segment, index) => (
<span key={index} className={segment.className || undefined}>
{segment.text}
</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) => {
@@ -104,7 +124,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
</Badge>
</div>
<span className="dark:text-gray-200 font-mono text-foreground whitespace-pre-wrap break-all">
{searchTerm ? highlightMessage(message, searchTerm) : message}
{highlightMessage(message, searchTerm || "")}
</span>
</div>
);

View File

@@ -1,5 +1,5 @@
export type LogType = "error" | "warning" | "success" | "info" | "debug";
export type LogVariant = "red" | "yellow" | "green" | "blue" | "orange";
export type LogType = "error" | "warning" | "success" | "info" | "debug";
export type LogVariant = "red" | "yellow" | "green" | "blue" | "orange";
export interface LogLine {
rawTimestamp: string | null;
@@ -12,6 +12,47 @@ interface LogStyle {
variant: LogVariant;
color: string;
}
interface AnsiSegment {
text: string;
className: string;
}
const ansiToTailwind: Record<number, string> = {
// Reset
0: "",
// Regular colors
30: "text-black dark:text-gray-900",
31: "text-red-600 dark:text-red-500",
32: "text-green-600 dark:text-green-500",
33: "text-yellow-600 dark:text-yellow-500",
34: "text-blue-600 dark:text-blue-500",
35: "text-purple-600 dark:text-purple-500",
36: "text-cyan-600 dark:text-cyan-500",
37: "text-gray-600 dark:text-gray-400",
// Bright colors
90: "text-gray-500 dark:text-gray-600",
91: "text-red-500 dark:text-red-600",
92: "text-green-500 dark:text-green-600",
93: "text-yellow-500 dark:text-yellow-600",
94: "text-blue-500 dark:text-blue-600",
95: "text-purple-500 dark:text-purple-600",
96: "text-cyan-500 dark:text-cyan-600",
97: "text-white dark:text-gray-300",
// Background colors
40: "bg-black",
41: "bg-red-600",
42: "bg-green-600",
43: "bg-yellow-600",
44: "bg-blue-600",
45: "bg-purple-600",
46: "bg-cyan-600",
47: "bg-white",
// Formatting
1: "font-bold",
2: "opacity-75",
3: "italic",
4: "underline",
};
const LOG_STYLES: Record<LogType, LogStyle> = {
error: {
@@ -138,11 +179,68 @@ export const getLogType = (message: string): LogStyle => {
if (
/(?:^|\s)(?:info|inf):?\s/i.test(lowerMessage) ||
/\[(info|log|debug|trace|server|db|api|http|request|response)\]/i.test(lowerMessage) ||
/\b(?:version|config|import|load|get|HTTP|PATCH|POST|debug)\b:?/i.test(lowerMessage)
/\[(info|log|debug|trace|server|db|api|http|request|response)\]/i.test(
lowerMessage,
) ||
/\b(?:version|config|import|load|get|HTTP|PATCH|POST|debug)\b:?/i.test(
lowerMessage,
)
) {
return LOG_STYLES.debug;
}
return LOG_STYLES.info;
};
export function parseAnsi(text: string) {
const segments: { text: string; className: string }[] = [];
let currentIndex = 0;
let currentClasses: string[] = [];
while (currentIndex < text.length) {
const escStart = text.indexOf("\x1b[", currentIndex);
// No more escape sequences found
if (escStart === -1) {
if (currentIndex < text.length) {
segments.push({
text: text.slice(currentIndex),
className: currentClasses.join(" "),
});
}
break;
}
// Add text before escape sequence
if (escStart > currentIndex) {
segments.push({
text: text.slice(currentIndex, escStart),
className: currentClasses.join(" "),
});
}
const escEnd = text.indexOf("m", escStart);
if (escEnd === -1) break;
// Handle multiple codes in one sequence (e.g., \x1b[1;31m)
const codesStr = text.slice(escStart + 2, escEnd);
const codes = codesStr.split(";").map((c) => Number.parseInt(c, 10));
if (codes.includes(0)) {
// Reset all formatting
currentClasses = [];
} else {
// Add new classes for each code
for (const code of codes) {
const className = ansiToTailwind[code];
if (className && !currentClasses.includes(className)) {
currentClasses.push(className);
}
}
}
currentIndex = escEnd + 1;
}
return segments;
}

View File

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

View File

@@ -6,13 +6,144 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { ShieldCheck } from "lucide-react";
import { AlertCircle, Link, ShieldCheck } from "lucide-react";
import { AddCertificate } from "./add-certificate";
import { DeleteCertificate } from "./delete-certificate";
export const ShowCertificates = () => {
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 (
<div className="h-full">
<Card className="bg-transparent h-full">
@@ -23,7 +154,7 @@ export const ShowCertificates = () => {
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-4 h-full">
{data?.length === 0 ? (
{!data?.length ? (
<div className="flex flex-col items-center gap-3">
<ShieldCheck className="size-8 self-center 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="grid grid-cols-1 sm:grid-cols-2 gap-6">
{data?.map((destination, index) => (
<div
key={destination.certificateId}
className="flex items-center justify-between border p-4 rounded-lg"
>
<span className="text-sm text-muted-foreground">
{index + 1}. {destination.name}
</span>
<div className="flex flex-row gap-3">
<DeleteCertificate
certificateId={destination.certificateId}
/>
{data.map((certificate, index) => {
const expiration = getExpirationStatus(
certificate.certificateData,
);
const chainInfo = getCertificateChainInfo(
certificate.certificateData,
);
return (
<div
key={certificate.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>
<AddCertificate />

View File

@@ -1,3 +1,4 @@
import { CodeEditor } from "@/components/shared/code-editor";
import {
Dialog,
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">
<code>
<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>
</code>
</div>

View File

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

View File

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

View File

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

View File

@@ -104,6 +104,7 @@ export const ProfileForm = () => {
.then(async () => {
await refetch();
toast.success("Profile Updated");
form.reset();
})
.catch(() => {
toast.error("Error to Update the profile");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,24 +11,53 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { HardDriveDownload } from "lucide-react";
import { toast } from "sonner";
export const UpdateWebServer = () => {
interface Props {
isNavbar?: boolean;
}
export const UpdateWebServer = ({ isNavbar }: Props) => {
const { mutateAsync: updateServer, isLoading } =
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 (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
className="relative w-full"
variant="secondary"
variant={isNavbar ? "outline" : "secondary"}
isLoading={isLoading}
>
<span className="absolute -right-1 -top-2 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
Update Server
{!isLoading && <HardDriveDownload className="h-4 w-4" />}
{!isLoading && (
<span className="absolute -right-1 -top-2 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
</span>
)}
{isLoading ? "Updating..." : buttonLabel}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
@@ -36,19 +65,12 @@ export const UpdateWebServer = () => {
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
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>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await updateServer();
toast.success("Please reload the browser to see the changes");
}}
>
Confirm
</AlertDialogAction>
<AlertDialogAction onClick={handleConfirm}>Confirm</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@@ -12,11 +12,16 @@ import { api } from "@/utils/api";
import { HeartIcon } from "lucide-react";
import Link from "next/link";
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 { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { buttonVariants } from "../ui/button";
const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 7;
export const Navbar = () => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false);
const router = useRouter();
const { data } = api.auth.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -29,6 +34,59 @@ export const Navbar = () => {
},
);
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 (
<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">
@@ -43,6 +101,11 @@ export const Navbar = () => {
</span>
</Link>
</div>
{isUpdateAvailable && (
<div>
<UpdateWebServer isNavbar />
</div>
)}
<Link
className={buttonVariants({
variant: "outline",

View File

@@ -14,14 +14,14 @@ const badgeVariants = cva(
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"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:
"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:
"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:
"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",
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",
"border-transparent select-none items-center whitespace-nowrap font-medium bg-emerald-600/20 dark:bg-emerald-500/15 dark:text-emerald-500 text-emerald-600 text-xs h-4 px-1 py-1 rounded-md",
blue: "border-transparent select-none items-center whitespace-nowrap font-medium bg-blue-600/20 dark:bg-blue-500/15 dark:text-blue-500 text-blue-600 text-xs h-4 px-1 py-1 rounded-md",
blank:
"border-transparent select-none items-center whitespace-nowrap font-medium dark:bg-white/15 bg-black/15 text-foreground text-xs h-4 px-1 py-1 rounded-md",
outline: "text-foreground",

View File

@@ -62,6 +62,7 @@ export const Secrets = (props: Props) => {
}
language="properties"
disabled={isVisible}
lineWrapping
placeholder={props.placeholder}
className="h-96 font-mono"
{...field}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.15.0",
"version": "v0.15.1",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -188,9 +188,9 @@ export const authRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
const currentAuth = await findAuthByEmail(ctx.user.email);
if (input.password) {
if (input.currentPassword || input.password) {
const correctPassword = bcrypt.compareSync(
input.password,
input.currentPassword || "",
currentAuth?.password || "",
);
if (!correctPassword) {
@@ -268,7 +268,9 @@ export const authRouter = createTRPCRouter({
return auth;
}),
verifyToken: protectedProcedure.mutation(async () => {
return true;
}),
one: adminProcedure.input(apiFindOneAuth).query(async ({ input }) => {
const auth = await findAuthById(input.id);
return auth;

View File

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

View File

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

View File

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

View File

@@ -1136,4 +1136,19 @@ export const templates: TemplateData[] = [
tags: ["search", "analytics"],
load: () => import("./elastic-search/index").then((m) => m.generate),
},
{
id: "onedev",
name: "OneDev",
version: "11.6.6",
description:
"Git server with CI/CD, kanban, and packages. Seamless integration. Unparalleled experience.",
logo: "onedev.png",
links: {
github: "https://github.com/theonedev/onedev/",
website: "https://onedev.io/",
docs: "https://docs.onedev.io/",
},
tags: ["self-hosted", "development"],
load: () => import("./onedev/index").then((m) => m.generate),
},
];

View File

@@ -1,41 +1,108 @@
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { docker } from "@dokploy/server/constants";
import { getServiceContainer } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
// import packageInfo from "../../../package.json";
const updateIsAvailable = async () => {
try {
const service = await getServiceContainer("dokploy");
export interface IUpdateData {
latestVersion: string | null;
updateAvailable: boolean;
}
const localImage = await docker.getImage(getDokployImage()).inspect();
return localImage.Id !== service?.ImageID;
} catch (error) {
return false;
}
export const DEFAULT_UPDATE_DATA: IUpdateData = {
latestVersion: null,
updateAvailable: false,
};
/** Returns current Dokploy docker image tag or `latest` by default. */
export const getDokployImageTag = () => {
return process.env.RELEASE_TAG || "latest";
};
export const getDokployImage = () => {
return `dokploy/dokploy:${process.env.RELEASE_TAG || "latest"}`;
return `dokploy/dokploy:${getDokployImageTag()}`;
};
export const pullLatestRelease = async () => {
try {
const stream = await docker.pull(getDokployImage(), {});
await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (err, res) =>
err ? reject(err) : resolve(res),
);
});
const newUpdateIsAvailable = await updateIsAvailable();
return newUpdateIsAvailable;
} catch (error) {}
return false;
const stream = await docker.pull(getDokployImage());
await new Promise((resolve, reject) => {
docker.modem.followProgress(stream, (err, res) =>
err ? reject(err) : resolve(res),
);
});
};
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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ export const buildRemoteDocker = async (
application: ApplicationNested,
logPath: string,
) => {
const { sourceType, dockerImage, username, password } = application;
const { registryUrl, dockerImage, username, password } = application;
try {
if (!dockerImage) {
@@ -65,7 +65,7 @@ echo "Pulling ${dockerImage}" >> ${logPath};
if (username && password) {
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};
exit 1;
fi