feat(ui-phase-20): history & audit view — filters, pagination, CSV export

Filterable by template name, status (success/error/dry_run/skipped), and
date range. Sortable by all columns. Expandable rows show blockers/warnings.
Checksum displayed as first 8 chars with full hash on hover tooltip.
Client-side CSV export. 50 records per page with prev/next pagination.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Huliganga 2026-04-21 11:41:05 -04:00
parent 11b646d3b7
commit 5bf2cc756a
1 changed files with 221 additions and 0 deletions

221
web/static/js/history.js Normal file
View File

@ -0,0 +1,221 @@
// History & Audit view — filterable, exportable migration history
import { api } from './api.js';
import { escHtml, formatDateTime, shortHash, downloadCsv, debounce } from './utils.js';
let _allRecords = [];
let _filter = { search: '', status: 'all', from: '', to: '' };
let _sort = { col: 'timestamp', dir: 'desc' };
const PAGE_SIZE = 50;
let _page = 0;
export async function renderHistory() {
const outlet = document.getElementById('router-outlet');
outlet.innerHTML = `<div class="empty-state"><div class="spinner"></div></div>`;
try {
const data = await api.migrate.history();
_allRecords = (data.history || []).reverse(); // newest first
} catch (e) {
outlet.innerHTML = `<div class="callout error"><span class="callout-icon">❌</span>Failed to load history: ${escHtml(e.message)}</div>`;
return;
}
_page = 0;
_render();
}
function _render() {
const outlet = document.getElementById('router-outlet');
const filtered = _applyFilter(_allRecords);
const page = filtered.slice(_page * PAGE_SIZE, (_page + 1) * PAGE_SIZE);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
outlet.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">History &amp; Audit</div>
<div class="page-subtitle">${_allRecords.length} total migration records</div>
</div>
<div class="page-actions">
<button class="btn btn-secondary btn-sm" id="btn-export-history"> Export CSV</button>
</div>
</div>
<!-- Filter bar -->
<div class="filter-bar">
<input type="search" class="search-input" id="hist-search"
placeholder="Search by template name…" value="${escHtml(_filter.search)}" />
<div class="filter-tabs">
<button class="filter-tab ${_filter.status === 'all' ? 'active' : ''}" data-status="all">All</button>
<button class="filter-tab ${_filter.status === 'success' ? 'active' : ''}" data-status="success">Success</button>
<button class="filter-tab ${_filter.status === 'error' ? 'active' : ''}" data-status="error">Errors</button>
<button class="filter-tab ${_filter.status === 'dry_run' ? 'active' : ''}" data-status="dry_run">Dry Run</button>
<button class="filter-tab ${_filter.status === 'skipped' ? 'active' : ''}" data-status="skipped">Skipped</button>
</div>
<div class="date-filter">
<label style="font-size:11px;color:var(--text-muted)">From:</label>
<input type="date" class="date-input" id="hist-from" value="${_filter.from}" />
<label style="font-size:11px;color:var(--text-muted)">To:</label>
<input type="date" class="date-input" id="hist-to" value="${_filter.to}" />
</div>
</div>
${filtered.length === 0 ? `
<div class="empty-state">
<div class="empty-state-icon">📋</div>
<div class="empty-state-title">${_allRecords.length ? 'No records match your filter' : 'No migration history yet'}</div>
<div class="empty-state-sub">${_allRecords.length ? 'Try clearing the search or filters.' : 'Run a migration to see history here.'}</div>
</div>
` : `
<div class="card">
<div class="table-wrap">
<table>
<thead>
<tr>
${_th('timestamp', 'Time')}
${_th('adobe_template_name', 'Template')}
${_th('action', 'Action')}
${_th('status', 'Status')}
<th>DocuSign ID</th>
<th>Checksum</th>
</tr>
</thead>
<tbody>
${page.map(r => _historyRow(r)).join('')}
</tbody>
</table>
</div>
${totalPages > 1 ? `
<div class="pagination">
<span>Showing ${_page * PAGE_SIZE + 1}${Math.min((_page + 1) * PAGE_SIZE, filtered.length)} of ${filtered.length}</span>
<div class="pagination-pages">
<button class="page-btn" id="pg-prev" ${_page === 0 ? 'disabled' : ''}> Prev</button>
<button class="page-btn" id="pg-next" ${_page >= totalPages - 1 ? 'disabled' : ''}>Next </button>
</div>
</div>` : ''}
</div>
`}
`;
_bindEvents(filtered);
}
function _th(col, label) {
const dir = _sort.col === col ? (_sort.dir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
return `<th class="sortable ${dir}" data-col="${col}">${label}</th>`;
}
function _historyRow(r) {
const statusBadge = r.status === 'success'
? `<span class="badge badge-green">${escHtml(r.action || 'success')}</span>`
: `<span class="badge badge-${r.status === 'skipped' ? 'gray' : r.status === 'dry_run' ? 'gray' : 'red'}">${escHtml(r.status || '—')}</span>`;
const checksum = r.checksum_sha256 || r.checksum || '';
return `
<tr class="row-expandable" data-expanded="false">
<td style="white-space:nowrap;font-size:12px">${(r.timestamp||'').slice(0,19).replace('T',' ')}</td>
<td>
<div class="table-name">${escHtml(r.adobe_template_name || r.adobe_template_id || '—')}</div>
<div class="table-sub">${escHtml(r.adobe_template_id || '')}</div>
</td>
<td>${escHtml(r.action || '—')}</td>
<td>${statusBadge}</td>
<td class="mono" style="font-size:11px">${r.docusign_template_id ? escHtml(r.docusign_template_id.slice(0,12)) + '…' : '—'}</td>
<td>
${checksum
? `<span class="checksum" title="${escHtml(checksum)}">${escHtml(shortHash(checksum))}</span>`
: '<span style="color:var(--text-muted)">—</span>'}
</td>
</tr>
${(r.blockers || r.warnings || r.error) ? `
<tr class="row-expanded-content" style="display:none">
<td colspan="6">
<div class="row-expand-body">
${(r.blockers||[]).map(b => `<div style="color:var(--error);font-size:12px">🚫 ${escHtml(b)}</div>`).join('')}
${(r.warnings||[]).map(w => `<div style="color:var(--warning);font-size:12px">⚠ ${escHtml(w)}</div>`).join('')}
${r.error ? `<div style="color:var(--error);font-size:12px">❌ ${escHtml(r.error)}</div>` : ''}
</div>
</td>
</tr>` : ''}
`;
}
function _applyFilter(records) {
let list = [...records];
if (_filter.search) {
const q = _filter.search.toLowerCase();
list = list.filter(r =>
(r.adobe_template_name || '').toLowerCase().includes(q) ||
(r.adobe_template_id || '').toLowerCase().includes(q)
);
}
if (_filter.status !== 'all') {
list = list.filter(r => r.status === _filter.status);
}
if (_filter.from) {
list = list.filter(r => r.timestamp >= _filter.from);
}
if (_filter.to) {
list = list.filter(r => r.timestamp <= _filter.to + 'T23:59:59');
}
list.sort((a, b) => {
const va = a[_sort.col] || '';
const vb = b[_sort.col] || '';
return _sort.dir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
return list;
}
function _bindEvents(filtered) {
document.getElementById('hist-search')?.addEventListener('input', debounce(e => {
_filter.search = e.target.value; _page = 0; _render();
}, 250));
document.querySelectorAll('.filter-tab[data-status]').forEach(btn => {
btn.addEventListener('click', () => { _filter.status = btn.dataset.status; _page = 0; _render(); });
});
document.getElementById('hist-from')?.addEventListener('change', e => { _filter.from = e.target.value; _page = 0; _render(); });
document.getElementById('hist-to')?.addEventListener('change', e => { _filter.to = e.target.value; _page = 0; _render(); });
document.querySelectorAll('th.sortable').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
_sort.dir = _sort.col === col && _sort.dir === 'asc' ? 'desc' : 'asc';
_sort.col = col; _page = 0; _render();
});
});
document.getElementById('pg-prev')?.addEventListener('click', () => { if (_page > 0) { _page--; _render(); } });
document.getElementById('pg-next')?.addEventListener('click', () => { _page++; _render(); });
// Expand rows
document.querySelectorAll('.row-expandable').forEach(row => {
row.addEventListener('click', () => {
const next = row.nextElementSibling;
if (next && next.classList.contains('row-expanded-content')) {
const open = next.style.display !== 'none';
next.style.display = open ? 'none' : 'table-row';
}
});
});
document.getElementById('btn-export-history')?.addEventListener('click', () => {
downloadCsv('migration-history.csv', filtered.map(r => ({
timestamp: r.timestamp || '',
template: r.adobe_template_name || r.adobe_template_id || '',
adobe_id: r.adobe_template_id || '',
docusign_id: r.docusign_template_id || '',
action: r.action || '',
status: r.status || '',
checksum: r.checksum_sha256 || '',
warnings: (r.warnings || []).join('; '),
})));
});
}