feat: bulk job deletion with multi-select UI (#481)

Closes #445
This commit is contained in:
Namit
2025-12-26 18:24:39 -05:00
committed by GitHub
parent 9ac5e7569b
commit 8af8e59b4f
2 changed files with 219 additions and 33 deletions

View File

@@ -1,38 +1,115 @@
import { rmSync } from "node:fs";
import { Elysia } from "elysia";
import { Elysia, t } from "elysia";
import { outputDir, uploadsDir } from "..";
import db from "../db/db";
import { WEBROOT } from "../helpers/env";
import { userService } from "./user";
import { Jobs } from "../db/types";
export const deleteJob = new Elysia().use(userService).get(
"/delete/:jobId",
async ({ params, redirect, user }) => {
const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.as(Jobs)
.get(user.id, params.jobId);
export const deleteJob = new Elysia()
.use(userService)
.get(
"/delete/:jobId",
async ({ params, redirect, user }) => {
const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.as(Jobs)
.get(user.id, params.jobId);
if (!job) {
return redirect(`${WEBROOT}/results`, 302);
}
if (!job) {
return redirect(`${WEBROOT}/results`, 302);
}
// delete the directories
rmSync(`${outputDir}${job.user_id}/${job.id}`, {
recursive: true,
force: true,
});
rmSync(`${uploadsDir}${job.user_id}/${job.id}`, {
recursive: true,
force: true,
});
// delete the directories
rmSync(`${outputDir}${job.user_id}/${job.id}`, {
recursive: true,
force: true,
});
rmSync(`${uploadsDir}${job.user_id}/${job.id}`, {
recursive: true,
force: true,
});
// delete the job
db.query("DELETE FROM jobs WHERE id = ?").run(job.id);
return redirect(`${WEBROOT}/history`, 302);
},
{
auth: true,
},
);
// delete the job
db.query("DELETE FROM jobs WHERE id = ?").run(job.id);
return redirect(`${WEBROOT}/history`, 302);
},
{
auth: true,
},
)
.post(
"/delete-multiple",
async ({ body, user, set }) => {
const { jobIds } = body;
if (!Array.isArray(jobIds) || jobIds.length === 0) {
set.status = 400;
return { success: false, message: "Invalid job IDs provided" };
}
const results = {
success: [] as string[],
failed: [] as { jobId: string; error: string }[],
};
// Process deletions sequentially for safety
for (const jobId of jobIds) {
try {
const job = db
.query("SELECT * FROM jobs WHERE user_id = ? AND id = ?")
.as(Jobs)
.get(user.id, jobId);
if (!job) {
results.failed.push({
jobId,
error: "Job not found or unauthorized",
});
continue;
}
// Delete the directories
try {
rmSync(`${outputDir}${job.user_id}/${job.id}`, {
recursive: true,
force: true,
});
} catch (error) {
console.error(`Failed to delete output directory for job ${jobId}:`, error);
}
try {
rmSync(`${uploadsDir}${job.user_id}/${job.id}`, {
recursive: true,
force: true,
});
} catch (error) {
console.error(`Failed to delete uploads directory for job ${jobId}:`, error);
}
// Delete the job from database
db.query("DELETE FROM jobs WHERE id = ?").run(job.id);
results.success.push(jobId);
} catch (error) {
results.failed.push({
jobId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
return {
success: results.failed.length === 0,
deleted: results.success.length,
failed: results.failed.length,
details: results,
};
},
{
auth: true,
body: t.Object({
jobIds: t.Array(t.String(), { maxItems: 100 }),
}),
},
);

View File

@@ -47,7 +47,24 @@ export const history = new Elysia().use(userService).get(
`}
>
<article class="article">
<h1 class="mb-4 text-xl">Results</h1>
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl">Results</h1>
<div id="delete-selected-container">
<button
id="delete-selected-btn"
class={`
flex btn-secondary flex-row gap-2 text-contrast
disabled:cursor-not-allowed disabled:opacity-50
`}
disabled
>
<DeleteIcon />{" "}
<span>
Delete Selected (<span id="selected-count">0</span>)
</span>
</button>
</div>
</div>
<table
class={`
w-full table-auto overflow-y-auto rounded bg-neutral-900 text-left
@@ -57,6 +74,19 @@ export const history = new Elysia().use(userService).get(
>
<thead>
<tr>
<th
class={`
px-2 py-2
sm:px-4
`}
>
<input
type="checkbox"
id="select-all"
class="h-4 w-4 cursor-pointer"
title="Select all"
/>
</th>
<th
class={`
px-2 py-2
@@ -112,6 +142,14 @@ export const history = new Elysia().use(userService).get(
{userJobs.map((job) => (
<>
<tr id={`job-row-${job.id}`}>
<td>
<input
type="checkbox"
class="h-4 w-4 cursor-pointer"
data-checkbox-type="job"
data-job-id={job.id}
/>
</td>
<td class="job-details-toggle cursor-pointer" data-job-id={job.id}>
<svg
id={`arrow-${job.id}`}
@@ -159,7 +197,7 @@ export const history = new Elysia().use(userService).get(
</td>
</tr>
<tr id={`details-${job.id}`} class="hidden">
<td colspan="6">
<td colspan="7">
<div class="p-2 text-sm text-neutral-500">
<div class="mb-1 font-semibold">Detailed File Information:</div>
{job.files_detailed.map((file: Filename) => (
@@ -196,26 +234,97 @@ export const history = new Elysia().use(userService).get(
<script>
{`
document.addEventListener('DOMContentLoaded', () => {
// Expand/collapse job details
const toggles = document.querySelectorAll('.job-details-toggle');
toggles.forEach(toggle => {
toggle.addEventListener('click', function() {
const jobId = this.dataset.jobId;
const detailsRow = document.getElementById(\`details-\${jobId}\`);
// The arrow SVG itself has the ID arrow-\${jobId}
const arrow = document.getElementById(\`arrow-\${jobId}\`);
if (detailsRow && arrow) {
detailsRow.classList.toggle("hidden");
if (detailsRow.classList.contains("hidden")) {
// Right-facing arrow (collapsed)
arrow.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />';
} else {
// Down-facing arrow (expanded)
arrow.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />';
}
}
});
});
// Checkbox management
const selectAllCheckbox = document.getElementById('select-all');
const jobCheckboxes = document.querySelectorAll('[data-checkbox-type="job"]');
const deleteSelectedBtn = document.getElementById('delete-selected-btn');
const deleteSelectedContainer = document.getElementById('delete-selected-container');
const selectedCountSpan = document.getElementById('selected-count');
function updateDeleteButton() {
const checkedBoxes = Array.from(jobCheckboxes).filter(cb => cb.checked);
if (checkedBoxes.length > 0) {
deleteSelectedBtn.disabled = false;
selectedCountSpan.textContent = checkedBoxes.length;
} else {
deleteSelectedBtn.disabled = true;
selectedCountSpan.textContent = '0';
}
}
selectAllCheckbox?.addEventListener('change', function() {
jobCheckboxes.forEach(checkbox => {
checkbox.checked = this.checked;
});
updateDeleteButton();
});
jobCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
const allChecked = Array.from(jobCheckboxes).every(cb => cb.checked);
const someChecked = Array.from(jobCheckboxes).some(cb => cb.checked);
if (selectAllCheckbox) {
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = someChecked && !allChecked;
}
updateDeleteButton();
});
});
deleteSelectedBtn?.addEventListener('click', async function() {
const checkedBoxes = Array.from(jobCheckboxes).filter(cb => cb.checked);
const jobIds = checkedBoxes.map(cb => cb.dataset.jobId);
if (jobIds.length === 0) return;
const confirmed = confirm(\`Are you sure you want to delete \${jobIds.length} job(s)? This action cannot be undone.\`);
if (!confirmed) return;
try {
const response = await fetch('${WEBROOT}/delete-multiple', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ jobIds }),
});
if (!response.ok) {
throw new Error(\`HTTP error! status: \${response.status}\`);
}
const result = await response.json();
if (result.success || result.deleted > 0) {
alert(\`Successfully deleted \${result.deleted} job(s).\${result.failed > 0 ? \` Failed to delete \${result.failed} job(s).\` : ''}\`);
window.location.reload();
} else {
alert('Failed to delete jobs. Please try again.');
}
} catch (error) {
console.error('Error deleting jobs:', error);
alert('An error occurred while deleting jobs. Please try again.');
}
});
});
`}
</script>