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 = `
| Database |
Table |
Rows |
Columns |
Status |
Last Scan |
`;
rows.forEach(r => {
const t = r.updated ? new Date(r.updated).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '—';
if (r.type === 'pending') {
html += `
| ${r.db_name} |
${r.table_name} |
— | — |
Pending |
— |
`;
return;
}
if (r.type === 'error' && r.table_name === '—') {
html += `
| ${r.db_name} |
${r.err_msg || 'Connection failed'} |
|
Error |
${t} |
`;
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 += `
| ${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} |
`;
});
html += '
';
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
` : `
`}
| Table | Total | Found | Miss | Err | % |
${rows}
`;
});
container.innerHTML = html || 'No data yet.
';
}
// ── Init ──
refreshData();
setInterval(refreshData, 5000);