const API_BASE = "/api"; let allSchemaData = []; let allImageData = []; let isImgScanning = false; const KNOWN_TABLES = [ 'tbl_site_anomaly', 'tbl_site_anomaly_history', 'tbl_site_anomaly_history_bkp' ]; const ALL_DBS = [ 'roadis_nh08','nursultancity','nursultanbypass','nh08_roadis','sh69_poc', 'sharjahrta','fedrta_poc','athens_poc','johannesburg_poc','uk_poc', 'rsrdc','ksaaramco_poc','rcyanbu_poc','sr_lnt','Wollondillyshire_poc', 'SouthAfrica_poc','rcjubail_poc','Wollondillyshire_poc2','Milestone_Infra', 'Dubai_Metro_POC','a1highwaypoc','Greece_Poc','gekterna_poc','alhayar_poc', 'parsons_poc','seekright_demo','croatiahighway_poc','seattlecity_poc', 'JHSW_POC','FMConway_Westminster_POC','airportroad_poc','Brisa_poc', 'Maple_Highways','cardifftrial_poc','JHSW','hktrpl_poc','qatar_poc', 'spain_poc','Tahakom_KSA','prs_tollways','JHNSW','E65','baysidecouncil_poc', 'seattle_poc','phoenix_poc','Peterborough','attiki_poc','atiki_poc', 'italy_poc','rga_ksahighways','Croatia_demo','croatiastateroad', 'Maple_ncrepe','pstrpl','DTI','Elevated_Road_Project','Australia_Poc', 'Toronto_poc','Kazhakhstan_POC','Highway_407_Poc','Aktau_City_Poc', 'Northern_Coastal_road','Sekura','Australia_FP','Applus_Australia', 'Japan','Australia_Demo','Irb','Panama','Florida','I595','Almaty_City', 'MTO','Presight_Abudhabi','Nel','MnDOT','Mexico' ]; // ── Navigation ── document.querySelectorAll('.nav-item').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); btn.classList.add('active'); document.getElementById(btn.dataset.target).classList.add('active'); }); }); // ── Filters ── function filterSchema() { const q = (document.getElementById('schema-search').value || '').toLowerCase(); const s = document.getElementById('schema-filter').value; const t = document.getElementById('schema-table-filter').value; renderSchema(allSchemaData, q, s, t); } function filterImages() { const q = (document.getElementById('image-search').value || '').toLowerCase(); renderImages(allImageData.filter(d => d.db_name.toLowerCase().includes(q))); } // ── Main fetch ── async function refreshData() { try { const opts = { credentials: 'include' }; const [sr, sr2, sr3] = await Promise.all([ fetch(`${API_BASE}/status`, opts), fetch(`${API_BASE}/schema/latest`, opts), fetch(`${API_BASE}/image/latest`, opts) ]); const st = await sr.json(); const sc = await sr2.json(); const im = await sr3.json(); // Scan state UI const msgs = []; const scanBar = document.getElementById('scan-bar'); const scanNotice = document.getElementById('scan-notice'); const scanFill = document.getElementById('scan-bar-fill'); const statusDot = document.getElementById('status-dot'); if (st.schema_scanning) { msgs.push(`Schema → ${st.schema_current_db || '…'}`); document.getElementById('schema-live-dot').classList.add('active'); } else { document.getElementById('schema-live-dot').classList.remove('active'); } if (st.image_scanning) { msgs.push(`Images → ${st.image_current_db || '…'}`); document.getElementById('image-live-dot').classList.add('active'); } else { document.getElementById('image-live-dot').classList.remove('active'); } if (msgs.length) { scanBar.classList.add('visible'); scanNotice.classList.add('visible'); document.getElementById('scan-notice-text').textContent = 'Scanning: ' + msgs.join(' · '); const done = Object.keys(sc).length; scanFill.style.width = Math.min((done / ALL_DBS.length) * 100, 99) + '%'; statusDot.classList.add('scanning'); document.getElementById('header-status').textContent = 'Scanning…'; } else { scanBar.classList.remove('visible'); scanNotice.classList.remove('visible'); statusDot.classList.remove('scanning'); document.getElementById('header-status').textContent = 'Up to date'; } // Build data allSchemaData = ALL_DBS.map(db => { const r = sc[db]; if (r) return { db_name: db, status: r.schema_status, tables: r.schema_tables || [], updated: r.last_updated, pending: false }; return { db_name: db, status: null, tables: [], updated: null, pending: true }; }); isImgScanning = st.image_scanning; allImageData = ALL_DBS.map(db => { const r = im[db]; if (r) return { db_name: db, stats: r.image_stats || [], updated: r.last_updated, pending: false }; return { db_name: db, stats: [], updated: null, pending: true }; }); // Chips const ok = allSchemaData.filter(d => !d.pending && d.status === 'Success').length; const err = allSchemaData.filter(d => !d.pending && d.status !== 'Success').length; const pend = allSchemaData.filter(d => d.pending).length; document.getElementById('chip-total').textContent = ALL_DBS.length; document.getElementById('chip-healthy').textContent = ok; document.getElementById('chip-errors').textContent = err; document.getElementById('chip-pending').textContent = pend; document.getElementById('nav-schema-status').textContent = `${ok} ok · ${err} err · ${pend} pending`; document.getElementById('nav-image-status').textContent = `${allImageData.filter(d => !d.pending).length} scanned`; populateTableDropdown(); filterSchema(); filterImages(); } catch(e) { console.error(e); document.getElementById('header-status').textContent = 'Offline'; document.getElementById('status-dot').classList.add('scanning'); } } function populateTableDropdown() { const filter = document.getElementById('schema-table-filter'); const currentVal = filter.value; const tables = new Set(); allSchemaData.forEach(db => { if (db.tables) db.tables.forEach(t => tables.add(t.table_name)); }); // Add known tables just in case we are in pending state and they aren't fully populated yet KNOWN_TABLES.forEach(t => tables.add(t)); const sorted = Array.from(tables).sort(); filter.innerHTML = ''; sorted.forEach(t => { const opt = document.createElement('option'); opt.value = t; opt.textContent = t; filter.appendChild(opt); }); if (sorted.includes(currentVal) || currentVal === 'all') { filter.value = currentVal; } else { filter.value = 'all'; } } // ══════════════════════════════════════ // SCHEMA RENDER // ══════════════════════════════════════ function renderSchema(data, q = '', statusF = 'all', tableF = 'all') { const container = document.getElementById('schema-table-container'); // Flatten to per-table rows let rows = []; data.forEach(db => { if (db.pending) { KNOWN_TABLES.forEach(tn => { rows.push({ db_name: db.db_name, table_name: tn, rows: null, cols: null, type: 'pending', updated: null }); }); return; } if (!db.tables || db.tables.length === 0) { rows.push({ db_name: db.db_name, table_name: '—', rows: null, cols: null, type: 'error', err_msg: db.status, updated: db.updated }); return; } db.tables.forEach(t => { rows.push({ db_name: db.db_name, table_name: t.table_name, rows: t.row_count, cols: t.column_count, type: t.status === 'Success' ? 'ok' : 'error', updated: db.updated }); }); }); // Filter if (q) rows = rows.filter(r => r.db_name.toLowerCase().includes(q)); if (statusF === 'success') rows = rows.filter(r => r.type === 'ok'); if (statusF === 'error') rows = rows.filter(r => r.type === 'error'); if (tableF !== 'all') rows = rows.filter(r => r.table_name === tableF); // Compute column mode per table for anomaly detection const colMode = {}; const allTableNames = [...new Set(rows.map(r => r.table_name).filter(t => t !== '—'))]; allTableNames.forEach(tn => { const vals = rows.filter(r => r.table_name === tn && r.cols !== null).map(r => r.cols); if (!vals.length) return; const freq = {}; vals.forEach(v => { freq[v] = (freq[v] || 0) + 1; }); colMode[tn] = parseInt(Object.entries(freq).sort((a,b) => b[1]-a[1])[0][0]); }); if (!rows.length) { container.innerHTML = '
No results match your filters.
'; return; } // Sort: errors first, pending last, then alpha rows.sort((a, b) => { if (a.type === 'error' && b.type !== 'error') return -1; if (a.type !== 'error' && b.type === 'error') return 1; if (a.type === 'pending' && b.type !== 'pending') return 1; if (a.type !== 'pending' && b.type === 'pending') return -1; return a.db_name.localeCompare(b.db_name); }); let html = ``; rows.forEach(r => { const t = r.updated ? new Date(r.updated).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '—'; if (r.type === 'pending') { html += ``; return; } if (r.type === 'error' && r.table_name === '—') { html += ``; return; } const expected = colMode[r.table_name]; const anomaly = expected !== undefined && r.cols !== null && r.cols !== expected; const colTip = anomaly ? `title="Expected ${expected} columns (most common)"` : ''; html += ``; }); html += '
Database Table Rows Columns Status Last Scan
${r.db_name} ${r.table_name} Pending
${r.db_name} ${r.err_msg || 'Connection failed'} Error ${t}
${r.db_name} ${r.table_name} ${r.rows !== null ? Number(r.rows).toLocaleString() : '—'} ${r.cols !== null ? r.cols : '—'}${anomaly ? ' ⚠' : ''} ${r.type === 'ok' ? '✓ OK' : '✗ Error'} ${t}
'; container.innerHTML = html; } // ══════════════════════════════════════ // IMAGE RENDER // ══════════════════════════════════════ function renderImages(data) { const container = document.getElementById('image-grid-container'); if (!data || !data.length) { container.innerHTML = '
No databases match.
'; return; } const sorted = [...data].sort((a, b) => { if (!a.pending && b.pending) return -1; if (a.pending && !b.pending) return 1; return a.db_name.localeCompare(b.db_name); }); let html = ''; sorted.forEach(db => { const map = {}; (db.stats || []).forEach(s => { map[s.table] = s; }); const scanned = Object.values(map); const agg = scanned.reduce((a, s) => { a.tot += s.total_rows_with_image || 0; a.ok += s.available || 0; a.miss += s.not_found || 0; a.err += s.error || 0; return a; }, {tot:0,ok:0,miss:0,err:0}); const pOk = agg.tot > 0 ? (agg.ok / agg.tot) * 100 : 0; const pMiss = agg.tot > 0 ? (agg.miss / agg.tot) * 100 : 0; const pErr = agg.tot > 0 ? (agg.err / agg.tot) * 100 : 0; const time = db.updated ? new Date(db.updated).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '—'; let badge, badgeClass; if (db.pending) { badge = isImgScanning ? 'Scanning…' : 'Pending'; badgeClass = 'b-pending'; } else { badge = pOk >= 95 ? 'Healthy' : pOk >= 70 ? 'Partial' : 'Poor'; badgeClass = pOk >= 95 ? 'b-ok' : pOk >= 70 ? 'b-warn' : 'b-err'; } let rows = ''; KNOWN_TABLES.forEach(tn => { const s = map[tn]; if (!s && db.pending) { rows += `${tn}${isImgScanning ? '⟳ scanning' : '· pending'}`; } else if (!s) { rows += `${tn}not in db`; } else { const tot = s.total_rows_with_image || 0; const ok = s.available || 0; const miss = s.not_found || 0; const err = s.error || 0; const pct = tot > 0 ? ((ok/tot)*100).toFixed(1) : '—'; const pc = tot === 0 ? '' : parseFloat(pct) >= 95 ? 'td-ok' : parseFloat(pct) >= 70 ? 'td-warn' : 'td-err'; rows += ` ${tn} ${tot.toLocaleString()} ${ok.toLocaleString()} ${miss.toLocaleString()} ${err.toLocaleString()} ${pct}${tot>0?'%':''} `; } }); html += `
${db.db_name}
${db.pending ? 'Awaiting scan' : `${scanned.length}/3 tables · ${time}`}
${badge}
${!db.pending ? `
${agg.tot.toLocaleString()}
Total
${agg.ok.toLocaleString()}
Found
${agg.miss.toLocaleString()}
Missing
${agg.err.toLocaleString()}
Errors
` : `
`} ${rows}
TableTotalFoundMissErr%
`; }); container.innerHTML = html || '
No data yet.
'; } // ── Init ── refreshData(); setInterval(refreshData, 5000);