Files
canteen-seed-import/web/static/index.html
T
shawn be9c34f51d Add GPS-only backup/restore and database wipe
- Fix backup.py PRESERVED_FIELDS column names (lat/lng -> latitude/longitude)
  to match actual DB schema (pre-existing bug)
- Add backup_gps() and restore_gps() dedicated GPS-only functions
- Add /api/gps/backup and /api/gps/restore API endpoints
- Add /api/gps/restore/upload for restoring from uploaded backup files
- Add /api/db/wipe endpoint to clear all asset records
- Update UI with GPS-only Backup button, Restore GPS from File picker,
  and Wipe All Assets with double-confirmation dialog
- Fix backup listing to scan both seed-data/ and project root
2026-05-24 23:07:12 -04:00

1084 lines
44 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🌱 Canteen Seed Import</title>
<style>
/* ═══════════════════════════════════════════════════════════════════════
DESIGN TOKENS — matching main app dark theme
═══════════════════════════════════════════════════════════════════════ */
:root {
--bg: #0d0e12;
--card: #161820;
--card2: #1c1e28;
--border: #252830;
--border2: #2e3140;
--text: #e4e4e7;
--text2: #8b8fa3;
--text3: #5b5f73;
--accent: #5b6ef7;
--accent2: #7c8cf8;
--accent-bg: #141a3a;
--green: #4ade80;
--green-bg: #1a2e1a;
--red: #f87171;
--red-bg: #2e1a1a;
--amber: #fbbf24;
--amber-bg: #2a2510;
--purple: #a78bfa;
--cyan: #22d3ee;
--radius: 14px;
--radius-sm: 10px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { overflow-x: hidden; background: var(--bg); color: var(--text); }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 0;
overflow-x: hidden;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px;
}
/* ─── Header ─────────────────────────────────────────────────────────── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
}
.header h1 {
font-size: 24px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.header h1 span.icon { font-size: 28px; }
.header .badge {
font-size: 12px;
padding: 4px 10px;
border-radius: 20px;
background: var(--accent-bg);
color: var(--accent2);
font-weight: 600;
}
/* ─── Cards ──────────────────────────────────────────────────────────── */
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
margin-bottom: 20px;
}
.card h2 {
font-size: 16px;
margin-bottom: 16px;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card h3 {
font-size: 14px;
margin-bottom: 8px;
color: var(--text);
}
/* ─── Upload zone ────────────────────────────────────────────────────── */
.upload-zone {
border: 2px dashed var(--border2);
border-radius: var(--radius);
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: var(--card2);
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--accent);
background: var(--accent-bg);
}
.upload-zone .icon { font-size: 48px; margin-bottom: 12px; }
.upload-zone .text { color: var(--text2); font-size: 15px; }
.upload-zone .sub { color: var(--text3); font-size: 13px; margin-top: 6px; }
.upload-zone input[type="file"] { display: none; }
/* ─── Buttons ────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border-radius: var(--radius-sm);
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) { background: var(--accent2); }
.btn-secondary {
background: var(--card2);
color: var(--text);
border: 1px solid var(--border2);
}
.btn-secondary:hover:not(:disabled) { border-color: var(--accent); }
.btn-danger {
background: var(--red-bg);
color: var(--red);
border: 1px solid var(--red);
}
.btn-sm {
padding: 5px 12px;
font-size: 12px;
}
.btn-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
/* ─── Stats grid ─────────────────────────────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--card2);
border-radius: var(--radius-sm);
padding: 16px;
text-align: center;
}
.stat-card .value {
font-size: 28px;
font-weight: 700;
color: var(--accent2);
}
.stat-card .value.green { color: var(--green); }
.stat-card .value.amber { color: var(--amber); }
.stat-card .value.red { color: var(--red); }
.stat-card .label {
font-size: 11px;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
/* ─── Table ──────────────────────────────────────────────────────────── */
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
thead th {
position: sticky;
top: 0;
background: var(--card2);
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.3px;
font-size: 10px;
padding: 10px 8px;
text-align: left;
white-space: nowrap;
border-bottom: 2px solid var(--border);
}
tbody td {
padding: 8px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
}
tbody tr:hover { background: var(--card2); }
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 20px;
font-size: 10px;
font-weight: 600;
}
.tag-high { background: var(--red-bg); color: var(--red); }
.tag-mid { background: var(--amber-bg); color: var(--amber); }
.tag-low { background: var(--green-bg); color: var(--green); }
.tag-yes { background: var(--accent-bg); color: var(--accent2); }
.tag-disney { background: #1a1030; color: var(--purple); }
.badge-db {
font-size: 11px; padding: 4px 10px; border-radius: 20px;
background: var(--accent-bg); color: var(--accent2); font-weight: 600;
}
.badge-db.green { background: var(--green-bg); color: var(--green); }
.badge-db.amber { background: var(--amber-bg); color: var(--amber); }
.badge-db.red { background: var(--red-bg); color: var(--red); }
/* ─── Progress ───────────────────────────────────────────────────────── */
.progress-bar {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
margin: 12px 0;
}
.progress-bar .fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.4s ease;
}
/* ─── Spinner ────────────────────────────────────────────────────────── */
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--border2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ─── Markdown report ────────────────────────────────────────────────── */
.report-content {
font-family: monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 600px;
overflow-y: auto;
background: var(--card2);
padding: 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.report-content h1, .report-content h2, .report-content h3 {
color: var(--accent2);
font-family: -apple-system, sans-serif;
margin: 16px 0 8px;
}
.report-content h1 { font-size: 18px; }
.report-content h2 { font-size: 15px; }
.report-content h3 { font-size: 13px; }
.report-content code {
background: var(--border);
padding: 1px 5px;
border-radius: 4px;
font-size: 12px;
}
/* ─── Step indicators ────────────────────────────────────────────────── */
.steps {
display: flex;
gap: 0;
margin-bottom: 32px;
}
.step {
flex: 1;
text-align: center;
padding: 10px;
font-size: 12px;
font-weight: 600;
color: var(--text3);
border-bottom: 3px solid var(--border);
transition: all 0.3s;
}
.step.active {
color: var(--accent2);
border-color: var(--accent);
}
.step.done {
color: var(--green);
border-color: var(--green);
}
/* ─── Toggle switches ────────────────────────────────────────────────── */
.toggle-row {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0;
}
.toggle-label {
font-size: 14px;
color: var(--text2);
}
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
}
.toggle-switch input { display: none; }
.toggle-slider {
position: absolute;
inset: 0;
background: var(--border2);
border-radius: 12px;
cursor: pointer;
transition: 0.3s;
}
.toggle-slider::before {
content: "";
position: absolute;
width: 18px;
height: 18px;
left: 3px;
bottom: 3px;
background: var(--text);
border-radius: 50%;
transition: 0.3s;
}
input:checked + .toggle-slider {
background: var(--accent);
}
input:checked + .toggle-slider::before {
transform: translateX(20px);
}
/* ─── Mode selector ──────────────────────────────────────────────────── */
.mode-selector {
display: flex;
gap: 10px;
margin: 16px 0;
}
.mode-option {
flex: 1;
padding: 16px;
border: 2px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
text-align: center;
transition: all 0.2s;
background: var(--card2);
}
.mode-option:hover { border-color: var(--accent); }
.mode-option.selected {
border-color: var(--accent);
background: var(--accent-bg);
}
.mode-option .title {
font-size: 15px;
font-weight: 600;
margin-bottom: 4px;
}
.mode-option .desc {
font-size: 12px;
color: var(--text3);
}
/* ─── Info banner ────────────────────────────────────────────────────── */
.info-banner {
background: var(--accent-bg);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
padding: 12px 16px;
font-size: 13px;
color: var(--accent2);
margin-bottom: 16px;
}
/* ─── Responsive ─────────────────────────────────────────────────────── */
@media (max-width: 600px) {
.container { padding: 16px 10px; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.header h1 { font-size: 18px; }
.upload-zone { padding: 32px 16px; }
.mode-selector { flex-direction: column; }
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<h1><span class="icon">🌱</span>Canteen Seed Import</h1>
<span class="badge" id="badgeStatus">Ready</span>
</div>
<!-- Dashboard Bar -->
<div class="card" id="cardDashboard" style="padding:16px;margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
<div style="font-size:13px;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:0.5px">📊 Database Status</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<span class="badge badge-db" id="dashTotal"></span>
<span class="badge badge-db" id="dashBackup"></span>
<button class="btn btn-sm btn-secondary" onclick="refreshDashboard()" style="padding:3px 10px;font-size:11px">🔄</button>
</div>
</div>
<div id="dashContent" style="font-size:13px;color:var(--text3)">Loading...</div>
</div>
<!-- Steps -->
<div class="steps">
<div class="step active" id="step1">1. Upload</div>
<div class="step" id="step2">2. Review</div>
<div class="step" id="step3">3. Import</div>
<div class="step" id="step4">4. Report</div>
</div>
<!-- Step 1: Upload ───────────────────────────────────────────────────── -->
<div class="card" id="cardUpload">
<h2>📤 Upload Seed Data</h2>
<div class="upload-zone" id="uploadZone" onclick="document.getElementById('fileInput').click()">
<div class="icon">📁</div>
<div class="text">Drop Excel file here or click to browse</div>
<div class="sub">.xlsx, .xls, or .csv</div>
<input type="file" id="fileInput" accept=".xlsx,.xls,.csv" onchange="handleFile(event)">
</div>
<div id="uploadStatus" style="margin-top:12px; font-size:13px; color:var(--text3); display:none;"></div>
<!-- Drag-drop events -->
<script>
const zone = document.getElementById('uploadZone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file) uploadFile(file);
});
</script>
</div>
<!-- Step 2: Review ───────────────────────────────────────────────────── -->
<div class="card" id="cardReview" style="display:none;">
<h2>📋 Parse Results</h2>
<div class="stats-grid" id="statsGrid"></div>
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px;">
<span style="font-size:13px; color:var(--text2);">Preview — first 50 of <strong id="totalCount"></strong> machines</span>
<span style="flex:1;"></span>
<button class="btn btn-sm btn-secondary" onclick="scrollToImport()">⬇ Skip to Import</button>
</div>
<div class="table-wrap">
<table id="previewTable">
<thead id="previewHead"></thead>
<tbody id="previewBody"></tbody>
</table>
</div>
<!-- Column policy info -->
<div style="margin-top:16px; padding:12px; background:var(--card2); border-radius:var(--radius-sm); font-size:12px; color:var(--text2);">
<strong style="color:var(--accent2);">🛡️ Preserve fields</strong> (never overwritten): <span id="preserveList"></span><br>
<strong style="color:var(--green);">✏️ Auto fields</strong> (always overwritten): <span id="autoList"></span>
</div>
</div>
<!-- Step 3: Import ───────────────────────────────────────────────────── -->
<div class="card" id="cardImport" style="display:none;">
<h2>🚀 Run Import</h2>
<!-- Mode selector -->
<div id="modeSelector">
<div style="font-size:14px; color:var(--text2); margin-bottom:8px;">Select import mode:</div>
<div class="mode-selector">
<div class="mode-option selected" id="modeSeed" onclick="selectMode('seed')">
<div class="title">🆕 Seed</div>
<div class="desc">Fresh database — all records inserted from scratch</div>
</div>
<div class="mode-option" id="modeUpdate" onclick="selectMode('update')">
<div class="title">🔄 Update</div>
<div class="desc">Upsert — GPS, photos, install dates preserved</div>
</div>
</div>
</div>
<!-- Options -->
<div class="toggle-row">
<label class="toggle-switch">
<input type="checkbox" id="toggleBackup" checked>
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">Backup existing GPS & photos before import</span>
</div>
<!-- DB path -->
<div style="margin:12px 0; font-size:12px; color:var(--text3);">
Target DB: <code style="color:var(--accent2);" id="dbPathDisplay">seed-data/assets.db</code>
</div>
<!-- Run button -->
<div class="btn-row" style="margin-top:20px;">
<button class="btn btn-primary" id="btnImport" onclick="runImport()">
🚀 Run Import
</button>
<span id="importStatus" style="font-size:13px; color:var(--text2);"></span>
</div>
<!-- Import result -->
<div id="importResult" style="display:none; margin-top:20px;"></div>
</div>
<!-- Step 4: Report ───────────────────────────────────────────────────── -->
<div class="card" id="cardReport" style="display:none;">
<h2>📄 Validation Report</h2>
<div style="margin-bottom:12px;">
<button class="btn btn-sm btn-secondary" id="btnRefreshReport" onclick="loadReport()">🔄 Refresh</button>
<span style="font-size:12px; color:var(--text3); margin-left:8px;" id="reportPath"></span>
</div>
<div class="report-content" id="reportContent">
<span style="color:var(--text3);">No report yet. Run an import first.</span>
</div>
</div>
<!-- Backup Management ───────────────────────────────────────────────── -->
<div class="card" id="cardBackup" style="margin-top:20px">
<h2>💾 Backup Management</h2>
<div class="btn-row" style="margin-bottom:8px">
<button class="btn btn-primary btn-sm" onclick="createBackup()">💾 Full Backup</button>
<button class="btn btn-sm" style="background:var(--accent2);color:#fff;border:none;cursor:pointer;border-radius:6px;padding:6px 14px;font-size:13px" onclick="createGpsBackup()">📍 GPS-Only Backup</button>
<span id="backupStatus" style="font-size:13px;color:var(--text2)"></span>
</div>
<div id="backupList">
<span style="color:var(--text3);font-size:13px">Loading backups...</span>
</div>
</div>
<!-- Database Management ────────────────────────────────────────────── -->
<div class="card" style="margin-top:20px;border-color:rgba(255,80,80,0.2);">
<h2 style="color:var(--red);">⚠️ Database Management</h2>
<div style="font-size:13px;color:var(--text3);margin-bottom:12px;">
These operations modify the underlying asset database directly.
Always <strong>backup GPS first</strong> to avoid losing coordinate data.
</div>
<div class="btn-row" style="gap:8px;flex-wrap:wrap;">
<button class="btn btn-danger btn-sm" onclick="restoreFromFile()">📍 Restore GPS from File</button>
<button class="btn btn-danger btn-sm" onclick="wipeDatabase()">🗑️ Wipe All Assets</button>
<span id="dbManageStatus" style="font-size:13px;color:var(--text2)"></span>
</div>
</div>
</div>
<script>
// ─── State ─────────────────────────────────────────────────────────────
let importMode = 'seed';
let sessionData = null;
// ─── Init ──────────────────────────────────────────────────────────────
(async function init() {
try {
// Load column policies
const colsRes = await fetch('/api/columns');
const cols = await colsRes.json();
document.getElementById('preserveList').textContent = cols.preserve.join(', ');
document.getElementById('autoList').textContent = cols.auto.join(', ');
// Load existing session
const sessRes = await fetch('/api/session');
sessionData = await sessRes.json();
if (sessionData.has_data) {
showParsedData(sessionData.parse_stats, sessionData.machine_count);
if (sessionData.last_import) {
showImportResult(sessionData.last_import);
}
}
// Load dashboard + backups
refreshDashboard();
loadBackupList();
} catch (e) {
console.error('Init error:', e);
}
})();
// ─── File handling ────────────────────────────────────────────────────
function handleFile(event) {
const file = event.target.files[0];
if (file) uploadFile(file);
}
async function uploadFile(file) {
const status = document.getElementById('uploadStatus');
status.style.display = 'block';
status.innerHTML = '<span class="spinner"></span> Parsing...';
document.getElementById('badgeStatus').textContent = 'Parsing...';
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: form });
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Upload failed');
}
const data = await res.json();
sessionData = data;
showParsedData(data.stats, data.total_rows);
document.getElementById('badgeStatus').textContent = 'Parsed ✓';
status.innerHTML = `✅ Parsed <strong>${data.total_rows.toLocaleString()}</strong> machines from <em>${esc(data.file_name)}</em>`;
} catch (e) {
status.innerHTML = `${esc(e.message)}`;
document.getElementById('badgeStatus').textContent = 'Error';
}
}
// ─── Build preview table ──────────────────────────────────────────────
function showParsedData(stats, totalRows) {
// Show cards
document.getElementById('cardReview').style.display = '';
document.getElementById('cardImport').style.display = '';
document.getElementById('cardReport').style.display = '';
// Stats
const grid = document.getElementById('statsGrid');
grid.innerHTML = `
<div class="stat-card"><div class="value">${fmt(totalRows)}</div><div class="label">Total Machines</div></div>
<div class="stat-card"><div class="value">${fmt(stats.disney)}</div><div class="label">Disney Properties</div></div>
<div class="stat-card"><div class="value amber">${fmt(stats.unknown_machines)}</div><div class="label">Unknown Machines</div></div>
<div class="stat-card"><div class="value">${fmt(stats.ocr_corrections)}</div><div class="label">OCR Corrections</div></div>
<div class="stat-card"><div class="value red">${fmt(stats.priority?.High || 0)}</div><div class="label">High Priority</div></div>
<div class="stat-card"><div class="value amber">${fmt(stats.priority?.Mid || 0)}</div><div class="label">Mid Priority</div></div>
<div class="stat-card"><div class="value green">${fmt(stats.priority?.Low || 0)}</div><div class="label">Low Priority</div></div>
`;
// Telemetry stats
if (stats.telemetry) {
const tItems = Object.entries(stats.telemetry)
.filter(([k]) => k !== 'None')
.sort(([,a], [,b]) => b - a)
.map(([k, v]) => `<span style="margin-right:8px;"><strong>${esc(k)}</strong>: ${v}</span>`)
.join('');
if (tItems) {
grid.innerHTML += `<div class="stat-card" style="grid-column:span 2;"><div style="font-size:13px;color:var(--text2);">${tItems}</div><div class="label">Telemetry Providers</div></div>`;
}
}
// Update count
document.getElementById('totalCount').textContent = fmt(totalRows);
// Update steps
setStep(2, 'active');
setStep(1, 'done');
// Load session for the import card
fetch('/api/session').then(r => r.json()).then(d => {
sessionData = d;
if (d.last_import) showImportResult(d.last_import);
});
// Scroll to review
document.getElementById('cardReview').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ─── Build preview table (called after upload returns preview data) ───
// The upload endpoint already returns preview data — build the table when we have it
// We monkey-patch the upload handling to also populate the table
const origShowParsedData = showParsedData;
showParsedData = function(stats, totalRows) {
origShowParsedData(stats, totalRows);
// Build preview table from session data
if (sessionData && sessionData.preview) {
buildPreviewTable(sessionData.preview, sessionData.columns_previewed);
}
};
function buildPreviewTable(preview, columns) {
const head = document.getElementById('previewHead');
const body = document.getElementById('previewBody');
// Headers
head.innerHTML = '<tr>' + columns.map(c => `<th>${esc(c)}</th>`).join('') + '</tr>';
// Rows
body.innerHTML = preview.map(row => {
return '<tr>' + columns.map(col => {
let val = row[col];
if (val == null || val === '') return '<td style="color:var(--text3);">—</td>';
// Special formatting
if (col === 'priority') {
const cls = val === 'High' ? 'tag-high' : val === 'Mid' ? 'tag-mid' : 'tag-low';
return `<td><span class="tag ${cls}">${esc(val)}</span></td>`;
}
if (col === 'is_disney' && val) {
return `<td><span class="tag tag-disney">🏰 ${esc(sessionData?.stats?.disney_park || '')}</span></td>`;
}
if (col === 'has_cashless') {
return `<td><span class="tag ${val ? 'tag-yes' : ''}">${val ? '✓' : '—'}</span></td>`;
}
if (col === 'yearly_sales' && typeof val === 'number') {
return `<td style="text-align:right;">$${val.toLocaleString()}</td>`;
}
return `<td>${esc(String(val))}</td>`;
}).join('') + '</tr>';
}).join('');
}
// ─── Mode selection ───────────────────────────────────────────────────
function selectMode(mode) {
importMode = mode;
document.getElementById('modeSeed').classList.toggle('selected', mode === 'seed');
document.getElementById('modeUpdate').classList.toggle('selected', mode === 'update');
}
// ─── Run import ───────────────────────────────────────────────────────
async function runImport() {
const btn = document.getElementById('btnImport');
const status = document.getElementById('importStatus');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Importing...';
status.textContent = '';
document.getElementById('badgeStatus').textContent = 'Importing...';
const backup = document.getElementById('toggleBackup').checked;
const params = new URLSearchParams({ mode: importMode, backup: backup });
try {
const res = await fetch('/api/import?' + params, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || 'Import failed');
}
const data = await res.json();
showImportResult(data);
document.getElementById('badgeStatus').textContent = 'Imported ✓';
setStep(3, 'done');
setStep(4, 'active');
// Load report
setTimeout(loadReport, 500);
} catch (e) {
status.innerHTML = `<span style="color:var(--red);">❌ ${esc(e.message)}</span>`;
document.getElementById('badgeStatus').textContent = 'Error';
} finally {
btn.disabled = false;
btn.innerHTML = '🚀 Run Import';
}
}
function showImportResult(data) {
const el = document.getElementById('importResult');
const stats = data.stats || {};
const inserted = (stats.inserted || 0).toLocaleString();
const updated = (stats.updated || 0).toLocaleString();
const preserved = (stats.preserved || 0).toLocaleString();
const errors = stats.errors || 0;
let html = '<div class="info-banner">✅ Import complete!</div>';
html += '<div class="stats-grid">';
if (inserted !== '0') html += `<div class="stat-card"><div class="value">${inserted}</div><div class="label">Inserted</div></div>`;
if (updated !== '0') html += `<div class="stat-card"><div class="value">${updated}</div><div class="label">Updated</div></div>`;
if (preserved !== '0') html += `<div class="stat-card"><div class="value green">${preserved}</div><div class="label">Preserved</div></div>`;
if (errors) html += `<div class="stat-card"><div class="value red">${errors}</div><div class="label">Errors</div></div>`;
html += `<div class="stat-card"><div class="value">${data.mode || '—'}</div><div class="label">Mode</div></div>`;
html += '</div>';
if (data.backup_path && !data.backup_path.startsWith('ERROR')) {
html += `<div style="font-size:12px; color:var(--green); margin-top:8px;">💾 Backup: <code>${esc(data.backup_path)}</code></div>`;
}
el.innerHTML = html;
el.style.display = '';
document.getElementById('cardReport').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ─── Report ───────────────────────────────────────────────────────────
async function loadReport() {
const el = document.getElementById('reportContent');
const pathEl = document.getElementById('reportPath');
el.innerHTML = '<span class="spinner"></span> Loading...';
try {
const res = await fetch('/api/report');
const data = await res.json();
if (data.content) {
el.innerHTML = renderMarkdown(data.content);
pathEl.textContent = data.path || '';
document.getElementById('cardReport').style.display = '';
setStep(4, 'active');
} else {
el.innerHTML = '<span style="color:var(--text3);">' + (data.note || 'No report available.') + '</span>';
}
} catch (e) {
el.innerHTML = `<span style="color:var(--red);">Error loading report: ${esc(e.message)}</span>`;
}
}
// ─── Helpers ──────────────────────────────────────────────────────────
function esc(s) {
if (s == null) return '';
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
function fmt(n) {
if (n == null) return '—';
return Number(n).toLocaleString();
}
function setStep(n, state) {
const el = document.getElementById('step' + n);
el.classList.remove('active', 'done');
if (state) el.classList.add(state);
}
function scrollToImport() {
document.getElementById('cardImport').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ─── Simple markdown → HTML (headings, bold, code) ────────────────────
function renderMarkdown(md) {
let html = esc(md);
// Headings
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Horizontal rules
html = html.replace(/^---$/gm, '<hr style="border-color:var(--border);margin:16px 0;">');
// Lists
html = html.replace(/^- (.+)$/gm, '• $1');
// Separator lines
html = html.replace(/^═+$/gm, '');
return html;
}
// ─── Dashboard ────────────────────────────────────────────────────
async function refreshDashboard() {
try {
const d = await fetch('/api/status').then(r => r.json());
const el = document.getElementById('dashContent');
document.getElementById('dashTotal').textContent = `📦 ${d.total_machines || 0} machines`;
const gps = d.gps_coverage?.with_gps || 0;
const total = d.total_machines || 1;
const pct = (gps / total * 100).toFixed(0);
const gpsClass = pct > 80 ? 'green' : pct > 50 ? 'amber' : 'red';
document.getElementById('dashBackup').textContent = d.backup_count > 0
? `💾 ${d.backup_count} backup${d.backup_count > 1 ? 's' : ''}`
: '💾 No backups';
const makes = Object.entries(d.by_make || {}).slice(0, 5).map(([k, v]) =>
`<span style="margin-right:8px"><strong>${esc(k)}</strong>: ${v}</span>`
).join('');
el.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin-bottom:8px">
<div><span style="color:var(--text2);">By class:</span> ${Object.entries(d.by_class || {}).map(([k,v]) => `${k}: ${v}`).join(', ')}</div>
<div><span style="color:var(--text2);">Top makes:</span> ${makes || '—'}</div>
<div><span style="color:var(--text2);">GPS:</span> <span class="badge-db ${gpsClass}">${gps}/${total} (${pct}%)</span></div>
<div><span style="color:var(--text2);">Priority:</span> ${Object.entries(d.by_priority || {}).map(([k,v]) => `${k}: ${v}`).join(', ')}</div>
</div>
${d.last_modified ? `<div style="font-size:11px;color:var(--text3)">Last modified: ${d.last_modified.slice(0,19)}</div>` : ''}
`;
} catch (e) {
document.getElementById('dashContent').textContent = '⚠️ ' + e.message;
}
}
// ─── Backup Management ────────────────────────────────────────────
async function createBackup() {
const statusEl = document.getElementById('backupStatus');
statusEl.innerHTML = '<span class="spinner"></span> Backing up...';
try {
const res = await fetch('/api/backup', { method: 'POST' });
if (!res.ok) throw new Error((await res.json()).detail || 'Backup failed');
const d = await res.json();
statusEl.innerHTML = `<span style="color:var(--green)">✅ Backup: ${esc(d.backup_path)}</span>`;
loadBackupList();
refreshDashboard();
} catch (e) {
statusEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(e.message)}</span>`;
}
}
async function loadBackupList() {
const el = document.getElementById('backupList');
try {
const res = await fetch('/api/backups');
const d = await res.json();
if (!d.backups?.length) {
el.innerHTML = '<span style="color:var(--text3);font-size:13px">No backups yet. Create one above.</span>';
return;
}
el.innerHTML = d.backups.map(b => `
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.04)">
<div>
<div style="font-weight:500;font-size:13px">📦 ${esc(b.filename)}</div>
<div style="font-size:11px;color:var(--text3)">${b.created} · ${b.records} records</div>
</div>
<div style="display:flex;gap:4px">
<button class="btn btn-sm btn-secondary" onclick="compareBackup('${esc(b.path)}')">Compare</button>
<button class="btn btn-sm btn-danger" onclick="restoreBackup('${esc(b.path)}')">Restore</button>
</div>
</div>
`).join('');
} catch (e) {
el.innerHTML = `<span style="color:var(--red);font-size:13px">❌ ${esc(e.message)}</span>`;
}
}
async function compareBackup(path) {
const el = document.getElementById('backupList');
el.innerHTML = '<span class="spinner"></span><span style="margin-left:8px;color:var(--text2)">Comparing...</span>';
try {
const res = await fetch('/api/compare?backup_path=' + encodeURIComponent(path));
if (!res.ok) throw new Error((await res.json()).detail || 'Compare failed');
const d = await res.json();
el.innerHTML = d.changes === 0
? '<div style="color:var(--green);font-size:13px">✅ No unexpected GPS/photo overwrites detected</div>'
: `<div style="color:var(--red);font-size:13px">⚠️ ${d.changes} field(s) changed. <a href="#" onclick="loadBackupList();return false">Back to list</a></div>`;
setTimeout(loadBackupList, 2500);
} catch (e) {
el.innerHTML = `<span style="color:var(--red);font-size:13px">❌ ${esc(e.message)}</span>`;
setTimeout(loadBackupList, 2000);
}
}
async function restoreBackup(path) {
if (!confirm('Restore GPS & photo fields from this backup?')) return;
const el = document.getElementById('backupList');
el.innerHTML = '<span class="spinner"></span><span style="margin-left:8px;color:var(--text2)">Restoring...</span>';
try {
const res = await fetch('/api/backup/restore?backup_path=' + encodeURIComponent(path));
if (!res.ok) throw new Error((await res.json()).detail || 'Restore failed');
const d = await res.json();
el.innerHTML = `<div style="color:var(--green);font-size:13px">✅ Restored from: ${esc(d.restored_from)}</div>`;
refreshDashboard();
setTimeout(loadBackupList, 2000);
} catch (e) {
el.innerHTML = `<span style="color:var(--red);font-size:13px">❌ ${esc(e.message)}</span>`;
setTimeout(loadBackupList, 2000);
}
}
// ─── GPS-only Backup ──────────────────────────────────────────────
async function createGpsBackup() {
const statusEl = document.getElementById('backupStatus');
statusEl.innerHTML = '<span class="spinner"></span> Backing up GPS...';
try {
const res = await fetch('/api/gps/backup', { method: 'POST' });
if (!res.ok) throw new Error((await res.json()).detail || 'GPS backup failed');
const d = await res.json();
statusEl.innerHTML = `<span style="color:var(--green)">✅ GPS backup: ${esc(d.backup_path)}</span>`;
loadBackupList();
refreshDashboard();
} catch (e) {
statusEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(e.message)}</span>`;
}
}
// ─── Restore GPS from File (file picker) ──────────────────────────
function restoreFromFile() {
// Create hidden file input for .json.gz files
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json.gz,.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const statusEl = document.getElementById('dbManageStatus');
statusEl.innerHTML = `<span class="spinner"></span> Uploading & restoring GPS from ${esc(file.name)}...`;
// Upload to temp location then restore
const form = new FormData();
form.append('backup_file', file);
try {
const res = await fetch('/api/gps/restore/upload', { method: 'POST', body: form });
if (!res.ok) throw new Error((await res.json()).detail || 'Restore failed');
const d = await res.json();
statusEl.innerHTML = `<span style="color:var(--green)">✅ GPS restored from: ${esc(d.restored_from)}</span>`;
refreshDashboard();
} catch (e) {
statusEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(e.message)}</span>`;
}
};
input.click();
}
// ─── Wipe Database ────────────────────────────────────────────────
async function wipeDatabase() {
if (!confirm('⚠️ This will DELETE ALL ASSET RECORDS from the database. This cannot be undone without a backup.\n\nHave you backed up GPS coordinates first?')) return;
if (!confirm('Final confirmation: Are you sure you want to delete ALL assets?')) return;
const statusEl = document.getElementById('dbManageStatus');
statusEl.innerHTML = '<span class="spinner"></span> Wiping database...';
try {
const res = await fetch('/api/db/wipe', { method: 'POST' });
if (!res.ok) throw new Error((await res.json()).detail || 'Wipe failed');
const d = await res.json();
statusEl.innerHTML = `<span style="color:var(--red)">🗑️ Deleted ${d.records_deleted} records. Database is now empty.</span>`;
refreshDashboard();
} catch (e) {
statusEl.innerHTML = `<span style="color:var(--red)">❌ ${esc(e.message)}</span>`;
}
}
</script>
</body>
</html>