Add Cantaloupe Sync admin UI page

- New 'Cantaloupe Sync' nav item in sidebar with sync icon
- Section 1: Sync Overview showing latest batch status, diff badges
- Section 2: Pending Batch Review with approve/reject buttons and diff detail
- Section 3: Import History with expandable rows for full diff view
- Added static file serving (FileResponse) to admin_server.py
- Fixed formatDate to handle space-separated SQLite datetimes
- Dark theme matching existing admin UI, vanilla JS with fetch()
This commit is contained in:
Leo
2026-05-21 19:38:36 -04:00
parent e9d18cb8b7
commit 070e8c2ddb
2 changed files with 394 additions and 2 deletions
+379 -2
View File
@@ -532,6 +532,62 @@
.sidebar .sidebar-header .sh-icon { font-size: 22px; }
.main { margin-left: 56px; padding: 16px; max-width: calc(100vw - 56px); }
}
/* ═══════════════════════════════════════════════════════════════════════
CANTALOUPE SYNC
═══════════════════════════════════════════════════════════════════════ */
.cs-diff-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 8px;
margin-bottom: 14px;
}
.cs-diff-badge {
background: var(--card2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
text-align: center;
}
.cs-diff-badge .csd-val { font-size: 24px; font-weight: 800; }
.cs-diff-badge .csd-label { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text2); margin-top: 2px; }
.cs-diff-badge.add .csd-val { color: var(--green); }
.cs-diff-badge.chg .csd-val { color: var(--amber); }
.cs-diff-badge.rem .csd-val { color: var(--red); }
.cs-diff-badge.unc .csd-val { color: var(--text2); }
.cs-batch-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
cursor: pointer;
transition: background 0.1s;
font-size: 13px;
}
.cs-batch-row:hover { background: rgba(255,255,255,0.03); }
.cs-batch-row .csb-status { width: 80px; flex-shrink: 0; }
.cs-batch-row .csb-time { width: 160px; flex-shrink: 0; color: var(--text2); }
.cs-batch-row .csb-rows { width: 80px; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; }
.cs-batch-row .csb-chevron { font-size: 11px; color: var(--text2); transition: transform 0.2s; flex-shrink: 0; }
.cs-batch-row.expanded .csb-chevron { transform: rotate(90deg); }
.cs-batch-detail {
display: none;
padding: 0 12px 14px 36px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.cs-batch-detail.open { display: block; }
.cs-changes-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 8px; }
.cs-changes-table th { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); color: var(--text2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.03em; }
.cs-changes-table td { padding: 6px 8px; border-bottom: 1px solid rgba(255,255,255,0.03); vertical-align: middle; }
.cs-changes-table .cs-old { color: var(--red); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-changes-table .cs-new { color: var(--green); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-changes-table .cs-arrow { color: var(--text2); padding: 0 4px; }
.cs-actions { display: flex; gap: 8px; margin-top: 12px; }
</style>
</head>
<body>
@@ -581,6 +637,9 @@
<button class="nav-item" data-page="export" onclick="switchPage('export')">
<span class="ni-icon">📤</span><span>Export</span>
</button>
<button class="nav-item" data-page="cantaloupe" onclick="switchPage('cantaloupe')">
<span class="ni-icon">🔄</span><span>Cantaloupe Sync</span>
</button>
</div>
<div class="sidebar-footer">
<button id="logoutBtn" class="logout-btn" onclick="doLogout()">🚪 Sign out</button>
@@ -747,6 +806,35 @@
</div>
</div>
<!-- ── CANTALOUPE SYNC ── -->
<div id="pageCantaloupe" class="page hidden">
<div class="page-title">
<span>Cantaloupe Sync</span>
<div class="pt-actions">
<button class="btn btn-sm btn-primary" onclick="runSync()" id="csSyncBtn">🔄 Sync Now</button>
<button class="btn btn-sm btn-outline" onclick="loadCantaloupeSync()">↻ Refresh</button>
</div>
</div>
<!-- Section 1: Sync Overview -->
<div class="card" id="csOverview">
<div class="card-title">Latest Sync Status</div>
<div id="csOverviewContent"><div class="loading-row"><div class="spinner"></div> Loading...</div></div>
</div>
<!-- Section 2: Pending Batch Review -->
<div class="card hidden" id="csPendingReview">
<div class="card-title">Pending Batch — Review Changes</div>
<div id="csPendingContent"></div>
</div>
<!-- Section 3: Import History -->
<div class="card">
<div class="card-title">Import History</div>
<div id="csHistoryContent"><div class="loading-row"><div class="spinner"></div> Loading...</div></div>
</div>
</div>
<!-- ── DETAIL PAGES ── -->
<div id="pageDetail" class="page hidden">
<div class="page-title">
@@ -873,6 +961,7 @@ function switchPage(page) {
else if (page === 'users') loadUsers();
else if (page === 'customers') loadCustomers();
else if (page === 'activity') loadActivity();
else if (page === 'cantaloupe') loadCantaloupeSync();
}
function closeDetail() {
if (AppState._prevPage) switchPage(AppState._prevPage);
@@ -1927,6 +2016,287 @@ async function confirmResetDb() {
}
}
// ═════════════════════════════════════════════════════════════════════════════
// CANTALOUPE SYNC
// ═════════════════════════════════════════════════════════════════════════════
let csBatches = [];
let csLatestBatch = null;
async function loadCantaloupeSync() {
try {
const [batches] = await Promise.all([
api('/api/admin/cantaloupe/batches?limit=20'),
]);
csBatches = batches || [];
// Find the latest batch
csLatestBatch = csBatches.length > 0 ? csBatches[0] : null;
renderCsOverview();
renderCsPendingReview();
renderCsHistory();
} catch (e) {
document.getElementById('csOverviewContent').innerHTML =
`<div style="color:var(--red);text-align:center;padding:12px;">Failed to load: ${esc(e.message)}</div>`;
document.getElementById('csHistoryContent').innerHTML = '';
}
}
// ── Section 1: Overview ──
function renderCsOverview() {
const el = document.getElementById('csOverviewContent');
if (!csLatestBatch) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">🔄</div>No sync batches yet. Click "Sync Now" to import data.</div>';
return;
}
const b = csLatestBatch;
const ds = b.diff_summary || {};
const statusTag = csStatusBadge(b.status);
const lastSync = formatDate(b.created_at);
const rowCount = b.row_count || 0;
const dsInfo = (ds.new_assets || ds.changed_assets || ds.removed_assets)
? `<div class="cs-diff-summary">
${ds.new_assets && ds.new_assets.length > 0 ? `<div class="cs-diff-badge add"><div class="csd-val">${ds.new_assets.length}</div><div class="csd-label">New</div></div>` : ''}
${ds.changed_assets && ds.changed_assets.length > 0 ? `<div class="cs-diff-badge chg"><div class="csd-val">${ds.changed_assets.length}</div><div class="csd-label">Changed</div></div>` : ''}
${ds.removed_assets && ds.removed_assets.length > 0 ? `<div class="cs-diff-badge rem"><div class="csd-val">${ds.removed_assets.length}</div><div class="csd-label">Removed</div></div>` : ''}
${ds.unchanged_assets && ds.unchanged_assets.length > 0 ? `<div class="cs-diff-badge unc"><div class="csd-val">${ds.unchanged_assets.length}</div><div class="csd-label">Unchanged</div></div>` : ''}
</div>` : '';
el.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<span style="font-size:14px;font-weight:600;">Batch #${b.id}</span>
${statusTag}
<span class="text-muted">${lastSync}</span>
<span class="text-muted">${rowCount} rows</span>
${b.error_message ? `<span class="text-danger text-sm">⚠ ${esc(b.error_message)}</span>` : ''}
</div>
${dsInfo}
`;
}
function csStatusBadge(status) {
const map = {
pending: '<span class="status-tag" style="background:var(--amber-bg);color:var(--amber);">PENDING</span>',
approved: '<span class="status-tag active">APPROVED</span>',
rejected: '<span class="status-tag" style="background:var(--red-bg);color:var(--red);">REJECTED</span>',
error: '<span class="status-tag" style="background:var(--red-bg);color:var(--red);">ERROR</span>',
};
return map[status] || `<span class="status-tag">${esc(status)}</span>`;
}
// ── Section 2: Pending Review ──
function renderCsPendingReview() {
const card = document.getElementById('csPendingReview');
const el = document.getElementById('csPendingContent');
// Find a pending batch
const pending = csBatches.find(b => b.status === 'pending');
if (!pending) {
card.classList.add('hidden');
return;
}
card.classList.remove('hidden');
const ds = pending.diff_summary || {};
const newCount = (ds.new_assets || []).length;
const chgCount = (ds.changed_assets || []).length;
const remCount = (ds.removed_assets || []).length;
el.innerHTML = `
<div style="margin-bottom:10px;">
<strong>Batch #${pending.id}</strong>
<span class="text-muted"> · ${formatDate(pending.created_at)} · ${pending.row_count} rows</span>
</div>
<div class="cs-diff-summary">
<div class="cs-diff-badge add"><div class="csd-val">${newCount}</div><div class="csd-label">New</div></div>
<div class="cs-diff-badge chg"><div class="csd-val">${chgCount}</div><div class="csd-label">Changed</div></div>
<div class="cs-diff-badge rem"><div class="csd-val">${remCount}</div><div class="csd-label">Removed</div></div>
</div>
<div id="csPendingDetail"></div>
<div class="cs-actions">
<button class="btn btn-green" onclick="approveBatch(${pending.id})">✓ Approve</button>
<button class="btn btn-danger" onclick="rejectBatch(${pending.id})">✕ Reject</button>
</div>
`;
// Load full detail for the pending batch
loadPendingBatchDetail(pending.id);
}
async function loadPendingBatchDetail(batchId) {
const el = document.getElementById('csPendingDetail');
try {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
renderBatchDiffDetail(el, batch);
} catch (e) {
el.innerHTML = `<div class="text-danger text-sm">Failed to load detail: ${esc(e.message)}</div>`;
}
}
function renderBatchDiffDetail(el, batch) {
const dr = batch.diff_rows || {};
const newItems = dr.new || [];
const removedItems = dr.removed || [];
const changedItems = dr.changed || [];
let html = '';
// New assets table
if (newItems.length > 0) {
html += `<div class="card-title" style="margin-top:8px;color:var(--green);">+ New Assets (${newItems.length})</div>`;
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th><th>Customer</th><th>Location</th></tr></thead><tbody>`;
html += newItems.map(r => `<tr>
<td><code class="mono">${esc(r.machine_id)}</code></td>
<td>${esc(r.name || '—')}</td>
<td>${esc(r.customer || '—')}</td>
<td>${esc(r.location || '—')}</td>
</tr>`).join('');
html += `</tbody></table>`;
}
// Changed assets table
if (changedItems.length > 0) {
html += `<div class="card-title" style="margin-top:8px;color:var(--amber);">~ Changed Assets (${changedItems.length})</div>`;
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th><th>Field</th><th>Old Value</th><th></th><th>New Value</th></tr></thead><tbody>`;
html += changedItems.map(r => {
const changes = r.changes || [];
const firstRow = `<td rowspan="${changes.length || 1}"><code class="mono">${esc(r.machine_id)}</code></td><td rowspan="${changes.length || 1}">${esc(r.name || '—')}</td>`;
if (changes.length === 0) {
return `<tr>${firstRow}<td colspan="4" class="text-muted">No field-level changes</td></tr>`;
}
return changes.map((ch, i) => {
const row = i === 0 ? firstRow : '';
return `<tr>${row}
<td style="font-weight:600;">${esc(ch.field)}</td>
<td class="cs-old">${esc(String(ch.old_value ?? '—'))}</td>
<td class="cs-arrow">→</td>
<td class="cs-new">${esc(String(ch.new_value ?? '—'))}</td>
</tr>`;
}).join('');
}).join('');
html += `</tbody></table>`;
}
// Removed assets table
if (removedItems.length > 0) {
html += `<div class="card-title" style="margin-top:8px;color:var(--red);"> Removed Assets (${removedItems.length})</div>`;
html += `<table class="cs-changes-table"><thead><tr><th>Machine ID</th><th>Name</th></tr></thead><tbody>`;
html += removedItems.map(r => `<tr>
<td><code class="mono">${esc(r.machine_id)}</code></td>
<td>${esc(r.name || '—')}</td>
</tr>`).join('');
html += `</tbody></table>`;
}
if (newItems.length === 0 && changedItems.length === 0 && removedItems.length === 0) {
html = '<div class="text-muted text-sm">No changes detected.</div>';
}
el.innerHTML = html;
}
// ── Section 3: Import History ──
function renderCsHistory() {
const el = document.getElementById('csHistoryContent');
if (csBatches.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No import history</div>';
return;
}
el.innerHTML = csBatches.map(b => {
const ds = b.diff_summary || {};
const newCount = (ds.new_assets || []).length;
const chgCount = (ds.changed_assets || []).length;
const remCount = (ds.removed_assets || []).length;
const summaryParts = [];
if (newCount) summaryParts.push(`+${newCount}`);
if (chgCount) summaryParts.push(`~${chgCount}`);
if (remCount) summaryParts.push(`${remCount}`);
return `
<div class="cs-batch-row" onclick="toggleBatchHistory(${b.id})" id="csBatchRow_${b.id}">
<span class="csb-status">${csStatusBadge(b.status)}</span>
<span class="csb-time">${formatDate(b.created_at)}</span>
<span class="csb-rows">${b.row_count} rows</span>
<span class="text-muted text-sm" style="flex:1;">${summaryParts.join(' ')}</span>
<span class="csb-chevron">▶</span>
</div>
<div class="cs-batch-detail" id="csBatchDetail_${b.id}">
<div class="loading-row"><div class="spinner"></div> Loading...</div>
</div>`;
}).join('');
}
async function toggleBatchHistory(batchId) {
const row = document.getElementById('csBatchRow_' + batchId);
const detail = document.getElementById('csBatchDetail_' + batchId);
if (!row || !detail) return;
const isExpanded = row.classList.contains('expanded');
if (isExpanded) {
row.classList.remove('expanded');
detail.classList.remove('open');
return;
}
row.classList.add('expanded');
detail.classList.add('open');
// Fetch detail if not already loaded
if (detail.dataset.loaded !== '1') {
try {
const batch = await api(`/api/admin/cantaloupe/batches/${batchId}`);
// Render into a temporary container, then swap
const tmp = document.createElement('div');
renderBatchDiffDetail(tmp, batch);
detail.innerHTML = tmp.innerHTML;
detail.dataset.loaded = '1';
} catch (e) {
detail.innerHTML = `<div class="text-danger text-sm">Failed to load: ${esc(e.message)}</div>`;
}
}
}
// ── Sync Now ──
async function runSync() {
const btn = document.getElementById('csSyncBtn');
btn.disabled = true;
btn.innerHTML = '<div class="spinner"></div> Syncing...';
try {
const result = await api('/api/admin/cantaloupe/sync', { method: 'POST' });
showToast('Sync completed — batch #' + result.id);
await loadCantaloupeSync();
} catch (e) {
showToast('Sync failed: ' + e.message, true);
} finally {
btn.disabled = false;
btn.innerHTML = '🔄 Sync Now';
}
}
// ── Approve / Reject ──
async function approveBatch(batchId) {
const confirmed = await showModal('Approve Batch',
`Apply all ${csLatestBatch && csLatestBatch.id === batchId ? 'pending ' : ''}changes from batch #${batchId} to live data?`,
'Approve', 'btn-green');
if (!confirmed) return;
try {
const result = await api(`/api/admin/cantaloupe/batches/${batchId}/approve`, { method: 'POST' });
showToast('Batch approved — ' + (result.applied_stats ? JSON.stringify(result.applied_stats) : 'done'));
await loadCantaloupeSync();
} catch (e) {
showToast('Approve failed: ' + e.message, true);
}
}
async function rejectBatch(batchId) {
const confirmed = await showModal('Reject Batch',
`Discard batch #${batchId}? This cannot be undone.`,
'Reject', 'btn-danger');
if (!confirmed) return;
try {
await api(`/api/admin/cantaloupe/batches/${batchId}/reject`, { method: 'POST' });
showToast('Batch rejected');
await loadCantaloupeSync();
} catch (e) {
showToast('Reject failed: ' + e.message, true);
}
}
// ═════════════════════════════════════════════════════════════════════════════
// HELPERS
// ═════════════════════════════════════════════════════════════════════════════
@@ -1937,9 +2307,16 @@ function esc(s) {
function formatDate(iso) {
if (!iso) return '—';
const d = new Date(iso + (iso.includes('T') ? '' : 'T00:00:00'));
// Handle space-separated datetime (SQLite format): "2026-05-21 23:16:21"
let normalized = iso;
if (!iso.includes('T') && iso.includes(' ')) {
normalized = iso.replace(' ', 'T');
} else if (!iso.includes('T')) {
normalized = iso + 'T00:00:00';
}
const d = new Date(normalized);
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function timeAgo(iso) {