226 lines
9.1 KiB
JavaScript
226 lines
9.1 KiB
JavaScript
// History & Audit view — filterable, exportable migration history
|
||
|
||
import { api } from './api.js';
|
||
import { escHtml, formatDateTime, shortHash, downloadCsv, debounce, renderFieldIssues, bindFieldIssueToggles } 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 & 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 hasIssues = (r.field_issues || []).length > 0;
|
||
const statusBadge = r.status === 'success'
|
||
? `<span class="badge badge-green">${escHtml(r.action || 'success')}</span>${hasIssues ? '<span class="badge badge-amber">partial</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 || (r.field_issues||[]).length) ? `
|
||
<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>` : ''}
|
||
${renderFieldIssues(r.field_issues)}
|
||
</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';
|
||
}
|
||
});
|
||
});
|
||
|
||
bindFieldIssueToggles();
|
||
|
||
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('; '),
|
||
})));
|
||
});
|
||
}
|