Initial commit
This commit is contained in:
391
frontend/app.js
Normal file
391
frontend/app.js
Normal file
@@ -0,0 +1,391 @@
|
||||
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 = '<option value="all">All Tables</option>';
|
||||
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 = '<div class="empty-state"><span>No results match your filters.</span></div>';
|
||||
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 = `<table class="data-tbl">
|
||||
<thead><tr>
|
||||
<th style="width:22%">Database</th>
|
||||
<th>Table</th>
|
||||
<th class="r">Rows</th>
|
||||
<th class="r">Columns</th>
|
||||
<th>Status</th>
|
||||
<th>Last Scan</th>
|
||||
</tr></thead>
|
||||
<tbody>`;
|
||||
|
||||
rows.forEach(r => {
|
||||
const t = r.updated ? new Date(r.updated).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '—';
|
||||
|
||||
if (r.type === 'pending') {
|
||||
html += `<tr class="row-pending">
|
||||
<td class="c-db">${r.db_name}</td>
|
||||
<td class="c-tbl">${r.table_name}</td>
|
||||
<td class="r c-rows">—</td><td class="r c-cols">—</td>
|
||||
<td><span class="badge b-pending">Pending</span></td>
|
||||
<td class="c-time">—</td>
|
||||
</tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (r.type === 'error' && r.table_name === '—') {
|
||||
html += `<tr class="row-err">
|
||||
<td class="c-db">${r.db_name}</td>
|
||||
<td class="c-err-msg" colspan="2">${r.err_msg || 'Connection failed'}</td>
|
||||
<td></td>
|
||||
<td><span class="badge b-err">Error</span></td>
|
||||
<td class="c-time">${t}</td>
|
||||
</tr>`;
|
||||
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 += `<tr class="${r.type === 'error' ? 'row-err' : ''}">
|
||||
<td class="c-db">${r.db_name}</td>
|
||||
<td class="c-tbl">${r.table_name}</td>
|
||||
<td class="r c-rows">${r.rows !== null ? Number(r.rows).toLocaleString() : '—'}</td>
|
||||
<td class="r c-cols ${anomaly ? 'warn' : ''}" ${colTip}>${r.cols !== null ? r.cols : '—'}${anomaly ? ' ⚠' : ''}</td>
|
||||
<td><span class="badge ${r.type === 'ok' ? 'b-ok' : 'b-err'}">${r.type === 'ok' ? '✓ OK' : '✗ Error'}</span></td>
|
||||
<td class="c-time">${t}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════
|
||||
// IMAGE RENDER
|
||||
// ══════════════════════════════════════
|
||||
function renderImages(data) {
|
||||
const container = document.getElementById('image-grid-container');
|
||||
if (!data || !data.length) {
|
||||
container.innerHTML = '<div class="empty-state"><span>No databases match.</span></div>';
|
||||
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 += `<tr><td><span class="td-name">${tn}</span></td><td colspan="5" class="td-nil">${isImgScanning ? '⟳ scanning' : '· pending'}</td></tr>`;
|
||||
} else if (!s) {
|
||||
rows += `<tr><td><span class="td-name absent">${tn}</span></td><td colspan="5" class="td-nil">not in db</td></tr>`;
|
||||
} 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 += `<tr>
|
||||
<td><span class="td-name">${tn}</span></td>
|
||||
<td class="td-num">${tot.toLocaleString()}</td>
|
||||
<td class="td-ok">${ok.toLocaleString()}</td>
|
||||
<td class="td-warn">${miss.toLocaleString()}</td>
|
||||
<td class="td-err">${err.toLocaleString()}</td>
|
||||
<td class="td-pct ${pc}">${pct}${tot>0?'%':''}</td>
|
||||
</tr>`;
|
||||
}
|
||||
});
|
||||
|
||||
html += `<div class="img-card${db.pending ? ' pending' : ''}">
|
||||
<div class="card-top">
|
||||
<div>
|
||||
<div class="card-db">${db.db_name}</div>
|
||||
<div class="card-meta">${db.pending ? 'Awaiting scan' : `${scanned.length}/3 tables · ${time}`}</div>
|
||||
</div>
|
||||
<span class="badge ${badgeClass}">${badge}</span>
|
||||
</div>
|
||||
|
||||
${!db.pending ? `
|
||||
<div class="card-totals">
|
||||
<div class="tot-cell"><div class="tot-v blue">${agg.tot.toLocaleString()}</div><div class="tot-k">Total</div></div>
|
||||
<div class="tot-cell"><div class="tot-v green">${agg.ok.toLocaleString()}</div><div class="tot-k">Found</div></div>
|
||||
<div class="tot-cell"><div class="tot-v amber">${agg.miss.toLocaleString()}</div><div class="tot-k">Missing</div></div>
|
||||
<div class="tot-cell"><div class="tot-v red">${agg.err.toLocaleString()}</div><div class="tot-k">Errors</div></div>
|
||||
</div>
|
||||
<div class="card-bar">
|
||||
<div class="bar-seg green" style="width:${pOk}%"></div>
|
||||
<div class="bar-seg amber" style="width:${pMiss}%"></div>
|
||||
<div class="bar-seg red" style="width:${pErr}%"></div>
|
||||
</div>` : `<div class="card-bar"><div class="bar-shimmer"></div></div>`}
|
||||
|
||||
<table class="bk-table">
|
||||
<colgroup>
|
||||
<col style="width: 38%">
|
||||
<col style="width: 15%">
|
||||
<col style="width: 15%">
|
||||
<col style="width: 12%">
|
||||
<col style="width: 10%">
|
||||
<col style="width: 10%">
|
||||
</colgroup>
|
||||
<thead><tr><th>Table</th><th>Total</th><th>Found</th><th>Miss</th><th>Err</th><th>%</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
container.innerHTML = html || '<div class="empty-state"><span>No data yet.</span></div>';
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
refreshData();
|
||||
setInterval(refreshData, 5000);
|
||||
150
frontend/index.html
Normal file
150
frontend/index.html
Normal file
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SeekRight Monitor</title>
|
||||
<meta name="description" content="Database schema health and image sync monitoring.">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/styles.css?v=6">
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-logo">SR</div>
|
||||
<div>
|
||||
<div class="brand-name">SeekRight</div>
|
||||
<div class="brand-sub">Monitor</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidenav">
|
||||
<button class="nav-item active" data-target="schema-tab" id="nav-schema">
|
||||
<span class="nav-ico">⬡</span>
|
||||
<div class="nav-info">
|
||||
<span class="nav-label">Schema Health</span>
|
||||
<span class="nav-sub" id="nav-schema-status">–</span>
|
||||
</div>
|
||||
<span class="live-dot" id="schema-live-dot"></span>
|
||||
</button>
|
||||
<button class="nav-item" data-target="image-tab" id="nav-image">
|
||||
<span class="nav-ico">◫</span>
|
||||
<div class="nav-info">
|
||||
<span class="nav-label">Image Sync</span>
|
||||
<span class="nav-sub" id="nav-image-status">–</span>
|
||||
</div>
|
||||
<span class="live-dot" id="image-live-dot"></span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<span>Auto-refresh</span>
|
||||
<span class="foot-val">5s</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="main">
|
||||
|
||||
<!-- Topbar -->
|
||||
<header class="topbar">
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-dot blue"></div>
|
||||
<div class="metric-val" id="chip-total">77</div>
|
||||
<div class="metric-key">Databases</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-dot green"></div>
|
||||
<div class="metric-val" id="chip-healthy">–</div>
|
||||
<div class="metric-key">Healthy</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-dot red"></div>
|
||||
<div class="metric-val" id="chip-errors">–</div>
|
||||
<div class="metric-key">Errors</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-dot grey"></div>
|
||||
<div class="metric-val" id="chip-pending">–</div>
|
||||
<div class="metric-key">Pending</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<div class="status-pill">
|
||||
<div class="status-dot" id="status-dot"></div>
|
||||
<span id="header-status">Loading…</span>
|
||||
</div>
|
||||
<button class="btn-refresh" onclick="refreshData()">↻ Refresh</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Scan progress bar -->
|
||||
<div class="scan-bar" id="scan-bar">
|
||||
<div class="scan-bar-fill" id="scan-bar-fill" style="width:0%"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scan notice -->
|
||||
<div class="scan-notice" id="scan-notice">
|
||||
<div class="scan-notice-dot"></div>
|
||||
<span id="scan-notice-text"></span>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
|
||||
<!-- Schema Tab -->
|
||||
<section id="schema-tab" class="tab active">
|
||||
<div class="tab-bar">
|
||||
<div>
|
||||
<div class="tab-title">Database Schema Health</div>
|
||||
<div class="tab-desc">Per-table view · Column anomalies auto-detected and highlighted</div>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<input type="text" class="f-input" id="schema-search" placeholder="Search database…" oninput="filterSchema()">
|
||||
<select class="f-select" id="schema-table-filter" onchange="filterSchema()">
|
||||
<option value="all">All Tables</option>
|
||||
</select>
|
||||
<select class="f-select" id="schema-filter" onchange="filterSchema()">
|
||||
<option value="all">All Status</option>
|
||||
<option value="success">Healthy</option>
|
||||
<option value="error">Errors only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tbl-wrap" id="schema-table-container">
|
||||
<div class="empty-state"><div class="spinner"></div><span>Waiting for first scan…</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Image Tab -->
|
||||
<section id="image-tab" class="tab">
|
||||
<div class="tab-bar">
|
||||
<div>
|
||||
<div class="tab-title">Image Sync Status</div>
|
||||
<div class="tab-desc">tbl_site_anomaly · tbl_site_anomaly_history · tbl_site_anomaly_history_bkp</div>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<input type="text" class="f-input" id="image-search" placeholder="Search database…" oninput="filterImages()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-grid" id="image-grid-container">
|
||||
<div class="empty-state"><div class="spinner"></div><span>Waiting for first scan…</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container" class="toast-wrap"></div>
|
||||
<script src="/static/app.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
517
frontend/styles.css
Normal file
517
frontend/styles.css
Normal file
@@ -0,0 +1,517 @@
|
||||
/* ─── Reset ─── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
/* ─── Design tokens ─── */
|
||||
:root {
|
||||
--black: #080808;
|
||||
--black2: #0f0f0f;
|
||||
--black3: #141414;
|
||||
--black4: #1a1a1a;
|
||||
--black5: #222222;
|
||||
|
||||
--line: rgba(255,255,255,0.07);
|
||||
--line2: rgba(255,255,255,0.12);
|
||||
|
||||
--white: #f5f5f5;
|
||||
--dim: #888;
|
||||
--dimmer: #555;
|
||||
--dimmest: #333;
|
||||
|
||||
--blue: #3b82f6;
|
||||
--blue-l: rgba(59,130,246,0.18);
|
||||
--green: #22c55e;
|
||||
--green-l: rgba(34,197,94,0.14);
|
||||
--amber: #f59e0b;
|
||||
--amber-l: rgba(245,158,11,0.14);
|
||||
--red: #ef4444;
|
||||
--red-l: rgba(239,68,68,0.14);
|
||||
|
||||
--sidebar: 210px;
|
||||
--font: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace;
|
||||
}
|
||||
|
||||
html { font-size: 13px; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--black2);
|
||||
color: var(--white);
|
||||
line-height: 1.5;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ─── Layout ─── */
|
||||
.layout { display: flex; height: 100vh; width: 100vw; }
|
||||
.main { margin-left: var(--sidebar); flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
/* ═══════════════════════════════════
|
||||
SIDEBAR
|
||||
═══════════════════════════════════ */
|
||||
.sidebar {
|
||||
width: var(--sidebar);
|
||||
background: var(--black);
|
||||
border-right: 1px solid var(--line);
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 16px 16px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand-logo {
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
letter-spacing: -0.03em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-name { font-size: 0.82rem; font-weight: 700; color: var(--white); letter-spacing: -0.01em; }
|
||||
.brand-sub { font-size: 0.63rem; color: var(--dimmer); text-transform: uppercase; letter-spacing: 0.07em; margin-top: 1px; }
|
||||
|
||||
/* Nav */
|
||||
.sidenav { flex: 1; padding: 10px 8px; display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
color: var(--dim);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
font-family: var(--font);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.nav-item:hover { background: var(--black3); color: var(--white); }
|
||||
.nav-item.active {
|
||||
background: rgba(59,130,246,0.08);
|
||||
color: var(--white);
|
||||
border-left-color: var(--blue);
|
||||
}
|
||||
.nav-ico { width: 14px; text-align: center; font-size: 0.85rem; flex-shrink: 0; opacity: 0.7; }
|
||||
.nav-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.nav-label{ font-size: 0.78rem; font-weight: 600; line-height: 1.3; }
|
||||
.nav-sub { font-size: 0.63rem; color: var(--dimmer); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
||||
.nav-item.active .nav-sub { color: #666; }
|
||||
|
||||
.live-dot {
|
||||
width: 5px; height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--amber);
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.live-dot.active { opacity: 1; animation: blink 1.4s ease infinite; }
|
||||
@keyframes blink { 0%,100%{ opacity:1; } 50%{ opacity:0.3; } }
|
||||
|
||||
.sidebar-foot {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 10px 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.67rem;
|
||||
color: var(--dimmest);
|
||||
}
|
||||
.foot-val { color: var(--dimmer); }
|
||||
|
||||
/* ═══════════════════════════════════
|
||||
TOPBAR
|
||||
═══════════════════════════════════ */
|
||||
.topbar {
|
||||
background: var(--black);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 20px;
|
||||
height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metrics { display: flex; gap: 6px; }
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: var(--black3);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.metric:hover { border-color: var(--line2); }
|
||||
.metric-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
||||
.metric-dot.blue { background: var(--blue); }
|
||||
.metric-dot.green { background: var(--green); }
|
||||
.metric-dot.red { background: var(--red); }
|
||||
.metric-dot.grey { background: var(--dimmer); }
|
||||
.metric-val { font-size: 0.8rem; font-weight: 700; color: var(--white); font-variant-numeric: tabular-nums; }
|
||||
.metric-key { font-size: 0.65rem; color: var(--dim); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
.topbar-right { display: flex; align-items: center; gap: 10px; }
|
||||
.status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--dim);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 20px;
|
||||
background: var(--black3);
|
||||
}
|
||||
.status-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
|
||||
.status-dot.scanning { background: var(--amber); animation: blink 1s infinite; }
|
||||
|
||||
.btn-refresh {
|
||||
padding: 5px 12px;
|
||||
background: var(--black3);
|
||||
border: 1px solid var(--line2);
|
||||
border-radius: 6px;
|
||||
color: var(--dim);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--font);
|
||||
transition: all 0.15s;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.btn-refresh:hover { background: var(--black5); color: var(--white); border-color: var(--line2); }
|
||||
.btn-refresh:active { transform: scale(0.97); }
|
||||
|
||||
/* ─── Scan Progress Bar ─── */
|
||||
.scan-bar {
|
||||
height: 2px;
|
||||
background: var(--black3);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: none;
|
||||
}
|
||||
.scan-bar.visible { display: block; }
|
||||
.scan-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899, #f59e0b);
|
||||
background-size: 200% 100%;
|
||||
animation: gradient-slide 2s linear infinite;
|
||||
transition: width 0.6s ease;
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
@keyframes gradient-slide { 0%{ background-position: 0% 0%; } 100%{ background-position: -200% 0%; } }
|
||||
|
||||
/* ─── Scan notice ─── */
|
||||
.scan-notice {
|
||||
padding: 7px 20px;
|
||||
background: rgba(245,158,11,0.06);
|
||||
border-bottom: 1px solid rgba(245,158,11,0.1);
|
||||
font-size: 0.73rem;
|
||||
color: #b38500;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.scan-notice.visible { display: flex; }
|
||||
.scan-notice-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--amber); animation: blink 1s infinite; flex-shrink: 0; }
|
||||
|
||||
/* ═══════════════════════════════════
|
||||
CONTENT
|
||||
═══════════════════════════════════ */
|
||||
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.tab { display: none; height: 100%; flex-direction: column; overflow: hidden; }
|
||||
.tab.active { display: flex; }
|
||||
|
||||
/* ─── Tab toolbar ─── */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
background: var(--black2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tab-title { font-size: 0.9rem; font-weight: 700; color: var(--white); letter-spacing: -0.02em; }
|
||||
.tab-desc { font-size: 0.7rem; color: var(--dim); margin-top: 2px; }
|
||||
.filters { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
.f-input, .f-select {
|
||||
padding: 5px 10px;
|
||||
background: var(--black3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
color: var(--white);
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--font);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.f-input:focus, .f-select:focus { border-color: rgba(59,130,246,0.4); }
|
||||
.f-input::placeholder { color: var(--dimmest); }
|
||||
.f-input { width: 190px; }
|
||||
.f-select { cursor: pointer; }
|
||||
.f-select option { background: #111; }
|
||||
|
||||
/* ═══════════════════════════════════
|
||||
SCHEMA TABLE
|
||||
═══════════════════════════════════ */
|
||||
.tbl-wrap { flex: 1; overflow: auto; }
|
||||
|
||||
.data-tbl {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.data-tbl thead th {
|
||||
padding: 9px 16px;
|
||||
text-align: left;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--dimmer);
|
||||
border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap;
|
||||
background: var(--black);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
}
|
||||
.data-tbl thead th.r { text-align: right; }
|
||||
|
||||
.data-tbl tbody tr {
|
||||
border-bottom: 1px solid var(--line);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.data-tbl tbody tr:last-child { border-bottom: none; }
|
||||
.data-tbl tbody tr:nth-child(even) { background: rgba(255,255,255,0.012); }
|
||||
.data-tbl tbody tr:hover td { background: rgba(59,130,246,0.05); }
|
||||
.data-tbl tbody tr.row-err { border-left: 2px solid var(--red); }
|
||||
.data-tbl tbody tr.row-err:hover td { background: rgba(239,68,68,0.04); }
|
||||
.data-tbl tbody tr.row-pending { opacity: 0.45; }
|
||||
|
||||
.data-tbl td {
|
||||
padding: 8px 16px;
|
||||
color: var(--dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-tbl td.r { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.data-tbl td.c-db { font-weight: 600; color: var(--white); max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.data-tbl td.c-tbl { font-family: var(--mono); font-size: 0.72rem; color: #667; }
|
||||
.data-tbl td.c-rows { color: var(--white); }
|
||||
.data-tbl td.c-cols { color: var(--dim); }
|
||||
.data-tbl td.c-cols.warn { color: var(--amber); font-weight: 600; }
|
||||
.data-tbl td.c-time { color: var(--dimmest); font-size: 0.72rem; }
|
||||
.data-tbl td.c-err-msg { color: var(--red); font-size: 0.72rem; }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.b-ok { background: var(--green-l); color: var(--green); border-color: rgba(34,197,94,0.15); }
|
||||
.b-err { background: var(--red-l); color: var(--red); border-color: rgba(239,68,68,0.15); }
|
||||
.b-warn { background: var(--amber-l); color: var(--amber); border-color: rgba(245,158,11,0.15); }
|
||||
.b-pending { background: rgba(255,255,255,0.04); color: var(--dimmer); }
|
||||
|
||||
/* ═══════════════════════════════════
|
||||
IMAGE GRID
|
||||
═══════════════════════════════════ */
|
||||
.card-grid {
|
||||
padding: 16px 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.img-card {
|
||||
background: var(--black);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.img-card:hover { border-color: var(--line2); }
|
||||
.img-card.pending { opacity: 0.55; }
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-db { font-size: 0.82rem; font-weight: 700; color: var(--white); margin-bottom: 2px; }
|
||||
.card-meta { font-size: 0.67rem; color: var(--dimmest); }
|
||||
|
||||
/* Totals row */
|
||||
.card-totals {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tot-cell {
|
||||
background: var(--black3);
|
||||
border-radius: 5px;
|
||||
padding: 6px 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.tot-v { font-size: 0.85rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.2; }
|
||||
.tot-v.blue { color: #60a5fa; }
|
||||
.tot-v.green { color: var(--green); }
|
||||
.tot-v.amber { color: var(--amber); }
|
||||
.tot-v.red { color: var(--red); }
|
||||
.tot-k { font-size: 0.6rem; color: var(--dimmest); text-transform: uppercase; letter-spacing: 0.04em; margin-top: 2px; }
|
||||
|
||||
/* Bar */
|
||||
.card-bar {
|
||||
height: 3px;
|
||||
background: var(--black5);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.bar-seg { height: 100%; transition: width 0.6s ease; }
|
||||
.bar-seg.green { background: var(--green); }
|
||||
.bar-seg.amber { background: var(--amber); }
|
||||
.bar-seg.red { background: var(--red); }
|
||||
.bar-shimmer {
|
||||
flex: 1;
|
||||
background: linear-gradient(90deg, var(--black3) 0%, var(--black5) 50%, var(--black3) 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.8s linear infinite;
|
||||
}
|
||||
@keyframes shimmer { 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
|
||||
|
||||
/* Breakdown mini-table */
|
||||
.bk-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.72rem;
|
||||
table-layout: fixed;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.bk-table thead th {
|
||||
padding: 5px 4px;
|
||||
font-size: 0.59rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--dimmest);
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: right;
|
||||
}
|
||||
.bk-table thead th:first-child { text-align: left; width: 44%; }
|
||||
.bk-table thead th:nth-child(n+2):nth-child(-n+5) { width: 11%; }
|
||||
.bk-table thead th:last-child { width: 9%; }
|
||||
|
||||
.bk-table tbody tr { border-bottom: 1px solid rgba(255,255,255,0.04); }
|
||||
.bk-table tbody tr:last-child { border-bottom: none; }
|
||||
.bk-table tbody tr:hover td { background: rgba(255,255,255,0.025); }
|
||||
.bk-table td {
|
||||
padding: 5px 4px;
|
||||
color: var(--dim);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.bk-table td:first-child { text-align: left; }
|
||||
.td-name { font-family: var(--mono); font-size: 0.68rem; color: var(--dim) !important; display: block; overflow: hidden; text-overflow: ellipsis; }
|
||||
.td-name.absent { color: var(--dimmest) !important; font-style: italic; }
|
||||
.td-ok { color: var(--green); font-variant-numeric: tabular-nums; }
|
||||
.td-warn { color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
.td-err { color: var(--red); font-variant-numeric: tabular-nums; }
|
||||
.td-num { font-variant-numeric: tabular-nums; }
|
||||
.td-pct { font-weight: 600; font-size: 0.7rem; }
|
||||
.td-nil { color: var(--dimmest); font-size: 0.68rem; font-style: italic; text-align: left !important; padding-left: 5px !important; }
|
||||
|
||||
/* ─── States ─── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 56px 20px;
|
||||
color: var(--dimmer);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.spinner {
|
||||
width: 18px; height: 18px;
|
||||
border: 1.5px solid var(--line2);
|
||||
border-top-color: var(--blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@keyframes spin { to{ transform: rotate(360deg); } }
|
||||
|
||||
/* ─── Toasts ─── */
|
||||
.toast-wrap { position: fixed; bottom: 16px; right: 16px; z-index: 999; display: flex; flex-direction: column; gap: 5px; }
|
||||
.toast {
|
||||
background: var(--black4);
|
||||
border: 1px solid var(--line2);
|
||||
border-left: 2px solid var(--blue);
|
||||
border-radius: 6px;
|
||||
padding: 8px 13px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--white);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
|
||||
animation: slide-up 0.2s ease;
|
||||
}
|
||||
@keyframes slide-up { from{ opacity:0; transform:translateY(6px); } to{ opacity:1; transform:translateY(0); } }
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { width: 48px; padding: 10px 6px; }
|
||||
.brand-name, .brand-sub, .nav-info, .live-dot, .sidebar-foot { display: none; }
|
||||
.nav-ico { margin: auto; font-size: 1rem; }
|
||||
.brand-logo { margin: auto; }
|
||||
.brand { padding: 12px 6px 10px; }
|
||||
.main { margin-left: 48px; }
|
||||
.card-grid { grid-template-columns: 1fr; padding: 12px; }
|
||||
.metrics { gap: 4px; }
|
||||
.metric-key { display: none; }
|
||||
.tab-bar { top: 52px; }
|
||||
}
|
||||
Reference in New Issue
Block a user