Compare commits
2 Commits
0a672619ea
...
5455e9b94c
| Author | SHA1 | Date | |
|---|---|---|---|
| 5455e9b94c | |||
| e2bc2254a6 |
@ -12,6 +12,8 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Save,
|
Save,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Layout } from "@/components/layout/Layout";
|
import { Layout } from "@/components/layout/Layout";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -135,13 +137,44 @@ const QuotaEditModal = ({
|
|||||||
const StorageDashboard = (): ReactElement => {
|
const StorageDashboard = (): ReactElement => {
|
||||||
const { primaryColor } = useAppTheme();
|
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 [stats, setStats] = useState<StorageStats | null>(null);
|
||||||
const [quota, setQuota] = useState<StorageQuota | null>(null);
|
const [quota, setQuota] = useState<StorageQuota | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
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 () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -243,6 +276,22 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
>
|
>
|
||||||
Quota Details
|
Quota Details
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{activeTab === "stats" && (
|
{activeTab === "stats" && (
|
||||||
@ -462,8 +511,130 @@ const StorageDashboard = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</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 && (
|
{isEditModalOpen && (
|
||||||
<QuotaEditModal
|
<QuotaEditModal
|
||||||
isOpen={isEditModalOpen}
|
isOpen={isEditModalOpen}
|
||||||
|
|||||||
@ -30,6 +30,8 @@ export interface FileAttachment {
|
|||||||
version: number;
|
version: number;
|
||||||
is_current_version: boolean;
|
is_current_version: boolean;
|
||||||
previous_version_id: string | null;
|
previous_version_id: string | null;
|
||||||
|
/** Reference to the physical file blob (dedup model) */
|
||||||
|
blob_id: string | null;
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
access_level: string;
|
access_level: string;
|
||||||
download_count: number;
|
download_count: number;
|
||||||
@ -267,9 +269,18 @@ export const fileAttachmentService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** DELETE /files/:id */
|
/**
|
||||||
delete: async (id: string, hard = false): Promise<{ success: boolean }> => {
|
* DELETE /files/:id
|
||||||
const response = await apiClient.delete(`/files/${id}`, { params: { hard } });
|
* @param hard - true: remove reference row from DB; false (default): soft-delete
|
||||||
|
* @param keepBlob - true: keep binary on disk even if last reference (Layer 3)
|
||||||
|
*/
|
||||||
|
delete: async (id: string, hard = false, keepBlob = false): Promise<{ success: boolean }> => {
|
||||||
|
const response = await apiClient.delete(`/files/${id}`, {
|
||||||
|
params: {
|
||||||
|
...(hard && { hard: 'true' }),
|
||||||
|
...(keepBlob && { keep_blob: 'true' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -387,6 +398,18 @@ export const fileAttachmentService = {
|
|||||||
}>(`/files/${id}/content`);
|
}>(`/files/${id}/content`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /files/blobs/purge-soft-deleted — admin only.
|
||||||
|
* Purges soft-deleted blobs with zero references older than olderThanHours.
|
||||||
|
* @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', {}, {
|
||||||
|
params: olderThanHours > 0 ? { older_than_hours: olderThanHours } : {},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default fileAttachmentService;
|
export default fileAttachmentService;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user