feat: add storage cleanup tab and implement blob purging functionality in StorageDashboard

This commit is contained in:
Yashwin 2026-06-01 16:38:09 +05:30
parent e2bc2254a6
commit 5455e9b94c
2 changed files with 173 additions and 6 deletions

View File

@ -12,6 +12,8 @@ import {
CheckCircle2,
Save,
Loader2,
Trash2,
AlertTriangle,
} from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import { cn } from "@/lib/utils";
@ -135,13 +137,44 @@ const QuotaEditModal = ({
const StorageDashboard = (): ReactElement => {
const { primaryColor } = useAppTheme();
const [activeTab, setActiveTab] = useState<"stats" | "quota">("stats");
const [activeTab, setActiveTab] = useState<"stats" | "quota" | "cleanup">("stats");
const [stats, setStats] = useState<StorageStats | null>(null);
const [quota, setQuota] = useState<StorageQuota | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
// Storage Cleanup (purgeSoftDeletedBlobs) states
const [olderThanHours, setOlderThanHours] = useState(24);
const [isPurging, setIsPurging] = useState(false);
const [purgeResult, setPurgeResult] = useState<number | null>(null);
const [purgeError, setPurgeError] = useState<string | null>(null);
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const handlePurge = async () => {
setIsConfirmModalOpen(false);
setIsPurging(true);
setPurgeResult(null);
setPurgeError(null);
try {
const res = await fileAttachmentService.purgeSoftDeletedBlobs(olderThanHours);
if (res.success) {
setPurgeResult(res.data.purged);
// Refresh usage stats and quota following successful purge
await loadData();
} else {
setPurgeError("Failed to execute purge on the storage provider.");
}
} catch (err: any) {
setPurgeError(
err?.response?.data?.error?.message || "Failed to purge unreferenced files."
);
console.error("Purge error:", err);
} finally {
setIsPurging(false);
}
};
const loadData = async () => {
setLoading(true);
try {
@ -243,6 +276,22 @@ const StorageDashboard = (): ReactElement => {
>
Quota Details
</button>
<button
onClick={() => setActiveTab("cleanup")}
className={cn(
"px-6 py-3 text-sm font-bold transition-all border-b-2",
activeTab === "cleanup"
? "text-[#0e1b2a]"
: "border-transparent text-[#9aa6b2] hover:text-[#475569]",
)}
style={
activeTab === "cleanup"
? { borderBottomColor: primaryColor, color: primaryColor }
: {}
}
>
Storage Cleanup
</button>
</div>
{activeTab === "stats" && (
@ -462,8 +511,130 @@ const StorageDashboard = (): ReactElement => {
</div>
</div>
)}
{activeTab === "cleanup" && (
<div className="space-y-6 animate-in slide-in-from-bottom-2 duration-300">
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-2xl overflow-hidden shadow-sm">
<div className="px-6 py-5 border-b border-[rgba(0,0,0,0.08)] flex items-center gap-2">
<Trash2
className="w-5 h-5 text-red-500"
/>
<h3 className="text-base font-black text-[#0e1b2a]">
Storage Maintenance & Cleanup
</h3>
</div>
<div className="p-6 space-y-6">
<div className="max-w-2xl">
<p className="text-sm text-[#475569] leading-relaxed">
When files are deleted, the system decrements their reference counts. Files marked as{" "}
<strong>soft-deleted with 0 active references</strong> are preserved temporarily on the storage provider
to allow background services (like AI embeddings, audits, or indexes) to finish processing them.
</p>
<p className="text-sm text-[#475569] mt-3 leading-relaxed">
Use this administrative tool to permanently purge these unreferenced physical files from the storage provider and reclaim space.
</p>
</div>
<div className="border border-red-100 rounded-xl p-5 bg-red-50/30 max-w-2xl flex gap-4">
<AlertTriangle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
<div>
<h4 className="text-sm font-bold text-red-800">Warning: Permanent Action</h4>
<p className="text-xs text-red-700 mt-1 leading-relaxed">
Purging files is irreversible. Once deleted from the physical storage provider (local disk, Azure, or AWS),
the file binaries cannot be recovered.
</p>
</div>
</div>
<div className="max-w-md pt-2 space-y-4">
<FormField
label="Purge Files Older Than (Hours)"
type="number"
value={olderThanHours}
onChange={(e) => setOlderThanHours(Math.max(0, parseInt(e.target.value) || 0))}
placeholder="e.g. 24 (0 = purge all eligible immediately)"
/>
<span className="text-[11px] text-[#9aa6b2] block -mt-2">
Set to 0 to instantly purge all unreferenced soft-deleted blobs, or specify hours (e.g., 24) to keep files deleted within that timeframe.
</span>
{purgeResult !== null && (
<div className="p-4 rounded-xl bg-green-50 border border-green-200 text-green-800 text-sm flex items-center gap-2">
<CheckCircle2 className="w-4.5 h-4.5 text-green-600 shrink-0" />
<span>
Successfully purged <strong>{purgeResult}</strong> orphaned file binary/binaries, freeing up storage space!
</span>
</div>
)}
{purgeError && (
<div className="p-4 rounded-xl bg-red-50 border border-red-200 text-red-800 text-sm">
{purgeError}
</div>
)}
<div className="pt-2">
<PrimaryButton
onClick={() => setIsConfirmModalOpen(true)}
disabled={isPurging}
className="bg-red-600 hover:bg-red-700 text-white font-bold flex items-center gap-2 border-red-600 shadow-sm"
>
{isPurging ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Purge Unreferenced Files
</PrimaryButton>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{isConfirmModalOpen && (
<Modal
isOpen={isConfirmModalOpen}
onClose={() => setIsConfirmModalOpen(false)}
title="Confirm Storage Purge"
maxWidth="sm"
footer={
<div className="flex gap-2 justify-end w-full">
<button
onClick={() => setIsConfirmModalOpen(false)}
disabled={isPurging}
className="px-4 py-2 border border-gray-300 rounded-xl text-sm font-medium hover:bg-gray-50 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
onClick={handlePurge}
disabled={isPurging}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-sm font-bold flex items-center gap-2 disabled:opacity-50 transition-colors shadow-sm"
>
{isPurging && <Loader2 className="w-4 h-4 animate-spin" />}
Yes, Purge Permanently
</button>
</div>
}
>
<div className="p-6 space-y-4">
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<div className="text-center space-y-2">
<h3 className="text-base font-bold text-gray-900">Are you absolutely sure?</h3>
<p className="text-sm text-gray-500 leading-relaxed">
This will permanently delete all unreferenced physical file binaries older than{" "}
<strong>{olderThanHours} hours</strong> from your storage provider. This action is irreversible.
</p>
</div>
</div>
</Modal>
)}
{isEditModalOpen && (
<QuotaEditModal
isOpen={isEditModalOpen}

View File

@ -138,8 +138,6 @@ export interface UploadFilesParams {
files: File[];
entity_type: string; // required
entity_id: string; // required — must be valid UUID
/** UUID from the suppliers table. When provided, file deduplication is scoped to this supplier. */
supplier_id?: string | null;
category?: string; // optional string label
category_id?: string; // optional
description?: string; // max 500 chars
@ -230,7 +228,6 @@ export const fileAttachmentService = {
formData.append('file', params.files[0]);
formData.append('entity_type', params.entity_type);
formData.append('entity_id', params.entity_id);
if (params.supplier_id) formData.append('supplier_id', params.supplier_id);
if (params.category) formData.append('category', params.category);
if (params.category_id) formData.append('category_id', params.category_id);
if (params.description) formData.append('description', params.description);
@ -250,7 +247,6 @@ export const fileAttachmentService = {
params.files.forEach((file) => formData.append('files', file));
formData.append('entity_type', params.entity_type);
formData.append('entity_id', params.entity_id);
if (params.supplier_id) formData.append('supplier_id', params.supplier_id);
if (params.category) formData.append('category', params.category);
if (params.category_id) formData.append('category_id', params.category_id);
if (params.description) formData.append('description', params.description);
@ -409,7 +405,7 @@ export const fileAttachmentService = {
* @param olderThanHours - 0 means purge all eligible blobs regardless of age.
*/
purgeSoftDeletedBlobs: async (olderThanHours = 0): Promise<{ success: boolean; data: { purged: number } }> => {
const response = await apiClient.post('/files/blobs/purge-soft-deleted', null, {
const response = await apiClient.post('/files/blobs/purge-soft-deleted', {}, {
params: olderThanHours > 0 ? { older_than_hours: olderThanHours } : {},
});
return response.data;