416 lines
21 KiB
Plaintext
416 lines
21 KiB
Plaintext
@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>
|
||
}
|