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
+15
View File
@@ -24,6 +24,7 @@ from typing import Optional
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from cantaloupe_sync import router as cantaloupe_router, create_sync_tables
@@ -354,6 +355,20 @@ app.add_middleware(
# Mount Cantaloupe sync routes
app.include_router(cantaloupe_router)
# Serve admin frontend SPA (single index.html, no external assets needed)
from fastapi.responses import FileResponse
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
@app.get("/")
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str = ""):
"""Serve the admin SPA — all routes return index.html."""
if FRONTEND_INDEX.is_file():
return FileResponse(str(FRONTEND_INDEX))
return JSONResponse({"detail": "Frontend not found"}, status_code=404)
# ─── Auth Middleware ────────────────────────────────────────────────────────
+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) {