Merge remote-tracking branch 'dokploy/canary' into check-for-updates

This commit is contained in:
Nicholas Penree
2024-12-21 13:47:01 -05:00
11 changed files with 367 additions and 96 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ import { Logo } from "../shared/logo";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { buttonVariants } from "../ui/button"; import { buttonVariants } from "../ui/button";
const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 15; const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 5;
export const Navbar = () => { export const Navbar = () => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false); const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false);

View File

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

View File

@@ -269,11 +269,11 @@ export const settingsRouter = createTRPCRouter({
message: "You are not authorized to access this admin", message: "You are not authorized to access this admin",
}); });
} }
await updateAdmin(ctx.user.authId, { const adminUpdated = await updateAdmin(ctx.user.authId, {
enableDockerCleanup: input.enableDockerCleanup, enableDockerCleanup: input.enableDockerCleanup,
}); });
if (admin.enableDockerCleanup) { if (adminUpdated?.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => { scheduleJob("docker-cleanup", "0 0 * * *", async () => {
console.log( console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`, `Docker Cleanup ${new Date().toLocaleString()}] Running...`,

View File

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

View File

@@ -34,7 +34,7 @@ export const templates: TemplateData[] = [
{ {
id: "plausible", id: "plausible",
name: "Plausible", name: "Plausible",
version: "v2.1.0", version: "v2.1.4",
description: description:
"Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.", "Plausible is a open source, self-hosted web analytics platform that lets you track website traffic and user behavior.",
logo: "plausible.svg", logo: "plausible.svg",

View File

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