feat: add ability to delete old deployments

This commit is contained in:
Marc Fernandez
2025-12-07 12:39:31 +01:00
parent f0400495b0
commit 33fb21bfe1
6 changed files with 234 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
import { Paintbrush } from "lucide-react";
import { Ban } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
@@ -35,7 +35,7 @@ export const CancelQueues = ({ id, type }: Props) => {
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
Cancel Queues
<Paintbrush className="size-4" />
<Ban className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>

View File

@@ -0,0 +1,78 @@
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
interface Props {
id: string;
type: "application" | "compose";
}
export const ClearDeployments = ({ id, type }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isLoading } =
type === "application"
? api.application.clearDeployments.useMutation()
: api.compose.clearDeployments.useMutation();
const { data: isCloud } = api.settings.isCloud.useQuery();
if (isCloud) {
return null;
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="w-fit" isLoading={isLoading}>
Clear deployments
<Paintbrush className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to clear old deployments?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all old deployment records and logs, keeping only the active deployment (the most recent successful one).
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
})
.then(async (result) => {
toast.success(`${result.deletedCount} old deployments cleared successfully`);
// Invalidate deployment queries to refresh the list
await utils.deployment.allByType.invalidate({
id,
type,
});
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -25,6 +25,7 @@ import {
import { api, type RouterOutputs } from "@/utils/api";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { CancelQueues } from "./cancel-queues";
import { ClearDeployments } from "./clear-deployments";
import { KillBuild } from "./kill-build";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
@@ -144,6 +145,9 @@ export const ShowDeployments = ({
</CardDescription>
</div>
<div className="flex flex-row items-center flex-wrap gap-2">
{(type === "application" || type === "compose") && (
<ClearDeployments id={id} type={type} />
)}
{(type === "application" || type === "compose") && (
<KillBuild id={id} type={type} />
)}

View File

@@ -1,6 +1,7 @@
import {
addNewService,
checkServiceAccess,
clearOldDeploymentsByApplicationId,
createApplication,
deleteAllMiddlewares,
findApplicationById,
@@ -734,6 +735,26 @@ export const applicationRouter = createTRPCRouter({
}
await cleanQueuesByApplication(input.applicationId);
}),
clearDeployments: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to clear deployments for this application",
});
}
const result = await clearOldDeploymentsByApplicationId(input.applicationId);
return {
success: true,
message: `${result.deletedCount} old deployments cleared successfully`,
deletedCount: result.deletedCount,
};
}),
killBuild: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {

View File

@@ -2,6 +2,7 @@ import {
addDomainToCompose,
addNewService,
checkServiceAccess,
clearOldDeploymentsByComposeId,
cloneCompose,
createCommand,
createCompose,
@@ -252,6 +253,26 @@ export const composeRouter = createTRPCRouter({
await cleanQueuesByCompose(input.composeId);
return { success: true, message: "Queues cleaned successfully" };
}),
clearDeployments: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
compose.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to clear deployments for this compose",
});
}
const result = await clearOldDeploymentsByComposeId(input.composeId);
return {
success: true,
message: `${result.deletedCount} old deployments cleared successfully`,
deletedCount: result.deletedCount,
};
}),
killBuild: protectedProcedure
.input(apiFindCompose)
.mutation(async ({ input, ctx }) => {

View File

@@ -831,3 +831,111 @@ export const findAllDeploymentsByServerId = async (serverId: string) => {
});
return deploymentsList;
};
export const clearOldDeploymentsByApplicationId = async (
applicationId: string,
) => {
// Get all deployments ordered by creation date (newest first)
const deploymentsList = await db.query.deployments.findMany({
where: eq(deployments.applicationId, applicationId),
orderBy: desc(deployments.createdAt),
});
// Find the most recent successful deployment (status "done")
const activeDeployment = deploymentsList.find(
(deployment) => deployment.status === "done",
);
// If there's an active deployment, keep it and remove all others
// If there's no active deployment, keep the most recent one and remove the rest
let deploymentsToKeep: string[] = [];
if (activeDeployment) {
deploymentsToKeep.push(activeDeployment.deploymentId);
} else if (deploymentsList.length > 0) {
// Keep the most recent deployment even if it's not "done"
deploymentsToKeep.push(deploymentsList[0]!.deploymentId);
}
const deploymentsToDelete = deploymentsList.filter(
(deployment) => !deploymentsToKeep.includes(deployment.deploymentId),
);
// Delete old deployments and their log files
for (const deployment of deploymentsToDelete) {
if (deployment.rollbackId) {
await removeRollbackById(deployment.rollbackId);
}
// Remove log file if it exists
const logPath = deployment.logPath;
if (logPath && logPath !== "." && existsSync(logPath)) {
try {
await fsPromises.unlink(logPath);
} catch (error) {
console.error(`Error removing log file ${logPath}:`, error);
}
}
// Delete deployment from database
await removeDeployment(deployment.deploymentId);
}
return {
deletedCount: deploymentsToDelete.length,
keptDeployment: deploymentsToKeep[0] || null,
};
};
export const clearOldDeploymentsByComposeId = async (composeId: string) => {
// Get all deployments ordered by creation date (newest first)
const deploymentsList = await db.query.deployments.findMany({
where: eq(deployments.composeId, composeId),
orderBy: desc(deployments.createdAt),
});
// Find the most recent successful deployment (status "done")
const activeDeployment = deploymentsList.find(
(deployment) => deployment.status === "done",
);
// If there's an active deployment, keep it and remove all others
// If there's no active deployment, keep the most recent one and remove the rest
let deploymentsToKeep: string[] = [];
if (activeDeployment) {
deploymentsToKeep.push(activeDeployment.deploymentId);
} else if (deploymentsList.length > 0) {
// Keep the most recent deployment even if it's not "done"
deploymentsToKeep.push(deploymentsList[0]!.deploymentId);
}
const deploymentsToDelete = deploymentsList.filter(
(deployment) => !deploymentsToKeep.includes(deployment.deploymentId),
);
// Delete old deployments and their log files
for (const deployment of deploymentsToDelete) {
if (deployment.rollbackId) {
await removeRollbackById(deployment.rollbackId);
}
// Remove log file if it exists
const logPath = deployment.logPath;
if (logPath && logPath !== "." && existsSync(logPath)) {
try {
await fsPromises.unlink(logPath);
} catch (error) {
console.error(`Error removing log file ${logPath}:`, error);
}
}
// Delete deployment from database
await removeDeployment(deployment.deploymentId);
}
return {
deletedCount: deploymentsToDelete.length,
keptDeployment: deploymentsToKeep[0] || null,
};
};