Initial commit
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model List<EntityPurgeStat>
|
||||
@{
|
||||
ViewData["Title"] = "Data Purge & Cleanup";
|
||||
var groups = Model.GroupBy(s => s.Group).ToList();
|
||||
var totalSoftDeleted = Model.Sum(s => s.Total);
|
||||
}
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
[data-bs-theme="dark"] .table-light th,
|
||||
[data-bs-theme="dark"] .table-light td {
|
||||
background-color: var(--bs-secondary-bg);
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
[data-bs-theme="dark"] .card {
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
[data-bs-theme="dark"] .card-header {
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
[data-bs-theme="dark"] #previewResults .bg-light {
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
[data-bs-theme="dark"] #confirmSummary {
|
||||
background-color: rgba(220,53,69,.15) !important;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
[data-bs-theme="dark"] .border-top {
|
||||
border-color: var(--bs-border-color) !important;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-trash3 me-2 text-danger"></i>Data Purge & Cleanup</h4>
|
||||
<small class="text-muted">Permanently remove soft-deleted records from the database</small>
|
||||
</div>
|
||||
<span class="badge bg-danger fs-6">@totalSoftDeleted.ToString("N0") soft-deleted records</span>
|
||||
</div>
|
||||
|
||||
@* Alert messages *@
|
||||
@if (TempData["PurgeSuccess"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-check-circle me-2"></i>@TempData["PurgeSuccess"]
|
||||
@if (TempData["PurgeDetail"] != null)
|
||||
{
|
||||
<pre class="mb-0 mt-2 small">@TempData["PurgeDetail"]</pre>
|
||||
}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Warning banner *@
|
||||
<div class="alert alert-warning d-flex gap-3 align-items-start mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-4 flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Destructive operation — this cannot be undone.</strong>
|
||||
Purging permanently deletes records from the database. Soft-deleted records are hidden from users but still occupy database space. Use this tool periodically to reclaim space and keep the database clean.
|
||||
Job photo blobs in Azure Storage are also deleted when purging job photo records.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
@* Left: Entity stats *@
|
||||
<div class="col-lg-8">
|
||||
@foreach (var group in groups)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-transparent border-bottom py-2">
|
||||
<h6 class="mb-0 text-muted text-uppercase fw-semibold" style="font-size:0.75rem;letter-spacing:.05em">
|
||||
@group.Key
|
||||
</h6>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0 small">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:36px"></th>
|
||||
<th>Entity</th>
|
||||
<th class="text-end" style="width:90px">Total</th>
|
||||
<th class="text-end" style="width:100px">0–30d</th>
|
||||
<th class="text-end" style="width:100px">30–90d</th>
|
||||
<th class="text-end" style="width:100px">>90d</th>
|
||||
<th style="width:130px">Oldest</th>
|
||||
<th style="width:42px">
|
||||
<input type="checkbox" class="form-check-input group-select-all"
|
||||
title="Select all in group"
|
||||
data-group="@group.Key" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var s in group)
|
||||
{
|
||||
<tr class="@(s.Total == 0 ? "opacity-50" : "")">
|
||||
<td class="text-center">
|
||||
<i class="bi @s.Icon text-secondary"></i>
|
||||
</td>
|
||||
<td>@s.Label</td>
|
||||
<td class="text-end">
|
||||
@if (s.Total > 0)
|
||||
{
|
||||
<span class="badge bg-secondary">@s.Total</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (s.DeletedLast30Days > 0)
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success">@s.DeletedLast30Days</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (s.Deleted30To90Days > 0)
|
||||
{
|
||||
<span class="badge bg-warning-subtle text-warning">@s.Deleted30To90Days</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (s.DeletedOlderThan90Days > 0)
|
||||
{
|
||||
<span class="badge bg-danger-subtle text-danger">@s.DeletedOlderThan90Days</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
@(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="form-check-input entity-select"
|
||||
name="entities"
|
||||
value="@s.EntityName"
|
||||
data-group="@group.Key"
|
||||
@(s.Total == 0 ? "disabled" : "") />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view for this group — shown on screens < 992px -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="px-3 pt-2 pb-1">
|
||||
<span class="text-muted text-uppercase fw-semibold" style="font-size:0.7rem;letter-spacing:.05em">@group.Key</span>
|
||||
</div>
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var s in group)
|
||||
{
|
||||
<div class="mobile-data-card @(s.Total == 0 ? "opacity-50" : "")">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-icon @(s.Total > 0 ? "bg-danger" : "bg-secondary")">
|
||||
<i class="bi @s.Icon"></i>
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@s.Label</h6>
|
||||
<small>Oldest: @(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">Total deleted</span>
|
||||
<span class="mobile-card-value">
|
||||
@if (s.Total > 0)
|
||||
{
|
||||
<span class="badge bg-secondary">@s.Total</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">0–30d / 30–90d / >90d</span>
|
||||
<span class="mobile-card-value">@s.DeletedLast30Days / @s.Deleted30To90Days / @s.DeletedOlderThan90Days</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<div class="form-check mb-0">
|
||||
<input type="checkbox" class="form-check-input entity-select"
|
||||
value="@s.EntityName"
|
||||
data-group="@group.Key"
|
||||
id="mobile-cb-@s.EntityName"
|
||||
@(s.Total == 0 ? "disabled" : "") />
|
||||
<label class="form-check-label small" for="mobile-cb-@s.EntityName">
|
||||
Select for purge
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Right: Purge controls *@
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm sticky-top" style="top:1rem">
|
||||
<div class="card-header bg-danger text-white py-2">
|
||||
<h6 class="mb-0"><i class="bi bi-trash3 me-2"></i>Purge Options</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
@* Threshold *@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Delete records older than</label>
|
||||
<select id="olderThanDays" class="form-select">
|
||||
<option value="30">30 days</option>
|
||||
<option value="60">60 days</option>
|
||||
<option value="90" selected>90 days</option>
|
||||
<option value="180">180 days (6 months)</option>
|
||||
<option value="365">365 days (1 year)</option>
|
||||
</select>
|
||||
<div class="form-text">Only soft-deleted records older than this threshold will be affected.</div>
|
||||
</div>
|
||||
|
||||
@* Select All *@
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="selectAll">
|
||||
<label class="form-check-label fw-semibold" for="selectAll">Select all entity types</label>
|
||||
</div>
|
||||
|
||||
@* Preview *@
|
||||
<button type="button" class="btn btn-outline-secondary w-100 mb-2" id="previewBtn">
|
||||
<i class="bi bi-eye me-2"></i>Preview
|
||||
</button>
|
||||
|
||||
@* Preview results *@
|
||||
<div id="previewResults" class="d-none mb-3">
|
||||
<div class="border rounded p-2 bg-light small">
|
||||
<strong class="d-block mb-1">Would delete:</strong>
|
||||
<div id="previewList"></div>
|
||||
<hr class="my-1">
|
||||
<strong id="previewTotal" class="text-danger"></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Execute form *@
|
||||
<form method="post" asp-action="Execute" id="purgeForm">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="olderThanDays" id="hiddenDays" value="90" />
|
||||
<div id="hiddenEntities"></div>
|
||||
|
||||
<button type="button" class="btn btn-danger w-100" id="purgeBtn" disabled>
|
||||
<i class="bi bi-trash3-fill me-2"></i>Purge Selected
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-text text-danger mt-2">
|
||||
<i class="bi bi-lock me-1"></i>Action is logged to Audit Log.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Confirm modal *@
|
||||
<div class="modal fade" id="confirmModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title"><i class="bi bi-exclamation-triangle me-2"></i>Confirm Purge</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>You are about to <strong class="text-danger">permanently delete</strong> records from the database. This <strong>cannot be undone</strong>.</p>
|
||||
<div id="confirmSummary" class="border rounded p-2 bg-danger bg-opacity-10 small mb-2"></div>
|
||||
<p class="mb-0 text-muted">Are you sure you want to continue?</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmPurgeBtn">
|
||||
<i class="bi bi-trash3-fill me-2"></i>Yes, Purge Permanently
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
(function () {
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const previewBtn = document.getElementById('previewBtn');
|
||||
const purgeBtn = document.getElementById('purgeBtn');
|
||||
const previewRes = document.getElementById('previewResults');
|
||||
const previewList = document.getElementById('previewList');
|
||||
const previewTotal = document.getElementById('previewTotal');
|
||||
const confirmModal = new bootstrap.Modal(document.getElementById('confirmModal'));
|
||||
const confirmSummary= document.getElementById('confirmSummary');
|
||||
|
||||
// ── Select all ──────────────────────────────────────────────────────────
|
||||
selectAll.addEventListener('change', () => {
|
||||
document.querySelectorAll('.entity-select:not(:disabled)').forEach(cb => {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
updatePurgeBtn();
|
||||
});
|
||||
|
||||
// ── Group select all ────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.group-select-all').forEach(ga => {
|
||||
ga.addEventListener('change', () => {
|
||||
document.querySelectorAll(`.entity-select[data-group="${ga.dataset.group}"]:not(:disabled)`)
|
||||
.forEach(cb => { cb.checked = ga.checked; });
|
||||
updatePurgeBtn();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.entity-select').forEach(cb => {
|
||||
cb.addEventListener('change', updatePurgeBtn);
|
||||
});
|
||||
|
||||
function getSelectedEntities() {
|
||||
// Deduplicate since mobile cards duplicate the checkboxes
|
||||
const seen = new Set();
|
||||
return [...document.querySelectorAll('.entity-select:checked')]
|
||||
.filter(cb => { if (seen.has(cb.value)) return false; seen.add(cb.value); return true; })
|
||||
.map(cb => cb.value);
|
||||
}
|
||||
|
||||
function updatePurgeBtn() {
|
||||
const any = getSelectedEntities().length > 0;
|
||||
purgeBtn.disabled = !any;
|
||||
previewRes.classList.add('d-none');
|
||||
}
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────
|
||||
previewBtn.addEventListener('click', async () => {
|
||||
const entities = getSelectedEntities();
|
||||
if (!entities.length) {
|
||||
alert('Select at least one entity type to preview.');
|
||||
return;
|
||||
}
|
||||
|
||||
previewBtn.disabled = true;
|
||||
previewBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Loading…';
|
||||
|
||||
const days = document.getElementById('olderThanDays').value;
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
|
||||
try {
|
||||
const resp = await fetch('@Url.Action("Preview")', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': token
|
||||
},
|
||||
body: JSON.stringify({ olderThanDays: parseInt(days), entities })
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
let html = '';
|
||||
let total = 0;
|
||||
data.forEach(r => {
|
||||
total += r.count;
|
||||
const cls = r.count > 0 ? 'text-danger' : 'text-muted';
|
||||
const oldest = r.oldest ? ` <span class="text-muted">(oldest: ${r.oldest})</span>` : '';
|
||||
html += `<div class="${cls}">${r.entity}: <strong>${r.count}</strong>${oldest}</div>`;
|
||||
});
|
||||
previewList.innerHTML = html || '<div class="text-muted">Nothing to delete.</div>';
|
||||
previewTotal.textContent = `Total: ${total.toLocaleString()} records`;
|
||||
previewRes.classList.remove('d-none');
|
||||
} catch (e) {
|
||||
alert('Preview failed: ' + e.message);
|
||||
} finally {
|
||||
previewBtn.disabled = false;
|
||||
previewBtn.innerHTML = '<i class="bi bi-eye me-2"></i>Preview';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Purge button → modal ────────────────────────────────────────────────
|
||||
purgeBtn.addEventListener('click', () => {
|
||||
const entities = getSelectedEntities();
|
||||
const days = document.getElementById('olderThanDays').value;
|
||||
|
||||
let summary = `<strong>Threshold:</strong> older than ${days} days<br>`;
|
||||
summary += `<strong>Entity types:</strong> ${entities.join(', ')}`;
|
||||
confirmSummary.innerHTML = summary;
|
||||
confirmModal.show();
|
||||
});
|
||||
|
||||
// ── Confirm → submit form ───────────────────────────────────────────────
|
||||
document.getElementById('confirmPurgeBtn').addEventListener('click', () => {
|
||||
const entities = getSelectedEntities();
|
||||
const days = document.getElementById('olderThanDays').value;
|
||||
|
||||
document.getElementById('hiddenDays').value = days;
|
||||
|
||||
const container = document.getElementById('hiddenEntities');
|
||||
container.innerHTML = '';
|
||||
entities.forEach(e => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = 'entities';
|
||||
inp.value = e;
|
||||
container.appendChild(inp);
|
||||
});
|
||||
|
||||
confirmModal.hide();
|
||||
document.getElementById('purgeForm').submit();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
Reference in New Issue
Block a user