diff --git a/dashboard/siro-admin/css/main.css b/dashboard/siro-admin/css/main.css index 490b5adb..37febf03 100644 --- a/dashboard/siro-admin/css/main.css +++ b/dashboard/siro-admin/css/main.css @@ -1098,3 +1098,86 @@ h1, h2, h3, h4, h5, h6 { .data-table .badge { white-space: nowrap; } + +/* Diagnostics panel */ +.card-note { + font-size: 0.82rem; + color: var(--text-muted); + line-height: 1.6; + margin-bottom: 1rem; +} + +.api-base-row { + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + align-items: center; +} + +.api-base-row .select-input { flex: 1; min-width: 240px; } +.api-base-row .form-input { flex: 1; min-width: 240px; padding-left: 1rem; } +.api-base-row .btn-primary { width: auto; padding: 0.55rem 1.1rem; } + +.diagnostics { + margin: 0; + padding: 1rem; + max-height: 420px; + overflow: auto; + border-radius: var(--radius-md); + background: rgba(2, 6, 23, 0.75); + border: 1px solid var(--border-color); + color: var(--text-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.76rem; + line-height: 1.65; + white-space: pre-wrap; + word-break: break-word; +} + +/* Generic module pages */ +.module-panels { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.panel-body { min-height: 40px; } + +.kpi-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 0.85rem; +} + +.kpi-tile { + padding: 0.95rem 1.1rem; + border-radius: var(--radius-md); + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-color); +} + +.kpi-tile-value { + font-family: 'Outfit', sans-serif; + font-size: 1.5rem; + font-weight: 600; + color: var(--text-main); + line-height: 1.2; + word-break: break-word; +} + +.kpi-tile-label { + margin-top: 0.3rem; + font-size: 0.78rem; + color: var(--text-muted); +} + +.sub-panel { margin-top: 1.4rem; } + +.sub-panel-title { + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-subtle); + margin-bottom: 0.75rem; +} diff --git a/dashboard/siro-admin/index.html b/dashboard/siro-admin/index.html index 27a4acac..255fdcfe 100644 --- a/dashboard/siro-admin/index.html +++ b/dashboard/siro-admin/index.html @@ -470,8 +470,35 @@
+

Current session

+ +
+
+

API endpoint

+
+

+ The mobile admin app points at a different host per country. If every panel is empty, + the console is most likely calling a backend that does not exist on this domain. +

+
+ + + +
+
+ +
+
+

Server diagnostics

+
+ + +
+
+
Press “Run diagnostics” to call every endpoint and print the raw server reply.
+
diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index e7e60375..a1a81b44 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -8,9 +8,22 @@ (() => { 'use strict'; - const API_BASE = '/backend'; const SESSION_KEY = 'siro_admin_user'; const FP_KEY = 'siro_web_fp'; + const API_BASE_KEY = 'siro_api_base'; + + // The Flutter admin app talks to a per-country host (see + // siro_admin/lib/constant/links.dart). The web console defaults to the + // backend deployed next to it, but the operator can repoint it. + const API_CANDIDATES = [ + { label: 'Same origin (/backend)', value: '/backend' }, + { label: 'Jordan — jordan-siro.intaleqapp.com', value: 'https://jordan-siro.intaleqapp.com/backend' }, + { label: 'Default — api.siromove.com', value: 'https://api.siromove.com/siro_v3' }, + { label: 'Syria — api-syria.siromove.com', value: 'https://api-syria.siromove.com/siro_v3' }, + { label: 'Egypt — api-egypt.siromove.com', value: 'https://api-egypt.siromove.com/siro_v3' }, + ]; + + let API_BASE = localStorage.getItem(API_BASE_KEY) || '/backend'; // ── Session state ──────────────────────────────────────────────────────── let session = null; // { id, name, role, jwt, issuedAt, expiresIn } @@ -29,9 +42,11 @@ deviceFingerprint = await resolveFingerprint(); if (el.fpPreview) el.fpPreview.textContent = deviceFingerprint.slice(0, 12) + '…'; + buildModules(); setupNavigation(); setupAuthEvents(); setupDataEvents(); + setupDiagnostics(); session = readSession(); if (session?.jwt) { @@ -53,6 +68,8 @@ 'driversTableBody', 'driversMeta', 'driversPrev', 'driversNext', 'passengersTableBody', 'approvalsTableBody', 'statusLegend', 'serviceMix', 'tripPerformance', 'sessionInfo', + 'apiBaseSelect', 'apiBaseCustom', 'saveApiBaseBtn', 'runDiagnosticsBtn', + 'copyDiagnosticsBtn', 'diagnosticsOutput', ].forEach((id) => { el[id] = $(id); }); } @@ -263,6 +280,7 @@ localStorage.removeItem(SESSION_KEY); session = null; stats = null; + loadedModules.clear(); showLogin(); if (message) toast(message, 'info'); } @@ -295,6 +313,10 @@ if (ok) { setConnection('live', 'Live database'); el.lastUpdated.textContent = 'Updated ' + new Date().toLocaleTimeString(); + } else if (session) { + setConnection('error', 'No data from API'); + el.lastUpdated.textContent = 'No data — check diagnostics'; + toast('Every endpoint failed. Open “Session & Security” → Run diagnostics.', 'danger'); } }); } @@ -571,6 +593,401 @@ `
${k}${esc(String(v))}
`).join(''); } + // ── Extended modules (parity with the Flutter admin app) ──────────────── + // Each entry becomes a sidebar item plus a lazily-loaded page. Panels are + // rendered by shape, not by hand-written field lists, so an endpoint that + // grows a column shows it without a code change here. + const MODULES = [ + { + id: 'liveOps', group: 'Realtime & Analytics', icon: 'ph-broadcast', title: 'Live Operations', + subtitle: 'Realtime fleet counters and the alerts that need attention now', + panels: [ + { title: 'Realtime counters', path: '/Admin/v2/realtime_dashboard.php' }, + { title: 'Smart alerts', path: '/Admin/v2/smart_alerts.php' }, + ], + }, + { + id: 'growth', group: 'Realtime & Analytics', icon: 'ph-trend-up', title: 'Growth', + subtitle: 'Daily signups for passengers and captains', + panels: [{ title: 'Growth', path: '/Admin/v2/analytics/growth.php' }], + }, + { + id: 'analyticsV2', group: 'Realtime & Analytics', icon: 'ph-chart-line', title: 'Advanced Analytics', + subtitle: 'Revenue, ranking and dashboard aggregates from the v2 engine', + panels: [ + { title: 'Revenue', path: '/Admin/v2/analytics/revenue.php' }, + { title: 'Driver ranking', path: '/Admin/v2/analytics/driver_ranking.php' }, + { title: 'Dashboard data', path: '/Admin/v2/analytics/dashboard_data.php' }, + ], + }, + { + id: 'financeV2', group: 'Finance', icon: 'ph-bank', title: 'Financial V2', + subtitle: 'Settlement runs and financial aggregates', + panels: [ + { title: 'Financial stats', path: '/Admin/v2/financial/stats.php' }, + { title: 'Settlements', path: '/Admin/v2/financial/settlements.php' }, + ], + }, + { + id: 'marketing', group: 'Growth & Pricing', icon: 'ph-megaphone', title: 'Marketing Intelligence', + subtitle: 'Market share, competitor price gaps, anomalies and campaign history', + panels: [ + { title: 'Market share', path: '/Admin/marketing/get_market_share_analytics.php' }, + { title: 'Price comparison', path: '/Admin/marketing/get_price_comparison.php' }, + { title: 'Market anomalies', path: '/Admin/marketing/get_market_anomalies.php' }, + { title: 'Surge opportunity index', path: '/Admin/marketing/surge_opportunity_index.php' }, + { title: 'Win-back hotspots', path: '/Admin/marketing/winback_hotspot_targets.php' }, + { title: 'Campaign log', path: '/Admin/marketing/get_campaigns_log.php' }, + ], + }, + { + id: 'pricing', group: 'Growth & Pricing', icon: 'ph-sliders', title: 'Pricing Engine', + subtitle: 'Stability log, AI predictions and the live price-gap heatmap', + panels: [ + { title: 'Pricing stability log', path: '/Admin/marketing/get_pricing_stability_log.php' }, + { title: 'AI price prediction', path: '/Admin/marketing/ai_price_prediction.php' }, + { title: 'Price gap heatmap', path: '/Admin/marketing/get_price_gap_heatmap.php' }, + { title: 'Telemetry', path: '/Admin/marketing/get_telemetry.php' }, + ], + }, + { + id: 'geofence', group: 'Growth & Pricing', icon: 'ph-map-trifold', title: 'Demand Heatmap', + subtitle: 'Geofenced demand density', + panels: [{ title: 'Heatmap', path: '/Admin/geofence/get_heatmap.php' }], + }, + { + id: 'quality', group: 'Quality', icon: 'ph-prohibit', title: 'Blacklist', + subtitle: 'Blocked captains and passengers', + panels: [{ title: 'Blacklist', path: '/Admin/v2/quality/blacklist_manager.php?action_type=get_all' }], + }, + { + id: 'scorecard', group: 'Quality', icon: 'ph-medal', title: 'Driver Scorecard', + subtitle: 'Behaviour and reliability scoring per captain', + panels: [{ title: 'Scorecard', path: '/Admin/v2/quality/driver_scorecard.php' }], + }, + { + id: 'transit', group: 'Transit', icon: 'ph-bus', title: 'Mawasalati Organisations', + subtitle: 'Registered transit organisations and their pending routes', + panels: [ + { title: 'Organisations', path: '/Admin/transit/org/list.php' }, + { title: 'Routes awaiting approval', path: '/Admin/transit/route/pending.php' }, + ], + }, + { + id: 'staff', group: 'Administration', icon: 'ph-identification-badge', title: 'Staff & Employees', + subtitle: 'Internal staff records', + panels: [{ title: 'Employees', path: '/Admin/employee/get.php' }], + }, + { + id: 'audit', group: 'Administration', icon: 'ph-scroll', title: 'Audit Log', + subtitle: 'Privileged actions recorded across the platform', + panels: [{ title: 'Audit entries', path: '/Admin/v2/security/audit_logs.php' }], + }, + { + id: 'errors', group: 'Administration', icon: 'ph-bug', title: 'Error Log', + subtitle: 'Last errors reported by the mobile apps', + panels: [{ title: 'Recent errors', path: '/Admin/error/error_list_last20.php' }], + }, + ]; + + const loadedModules = new Set(); + + function buildModules() { + const menu = document.querySelector('.sidebar-menu'); + const main = document.querySelector('.content-body'); + if (!menu || !main) return; + + const groups = [...new Set(MODULES.map((m) => m.group))]; + groups.forEach((group) => { + const label = document.createElement('div'); + label.className = 'menu-label'; + label.textContent = group; + menu.appendChild(label); + + MODULES.filter((m) => m.group === group).forEach((mod) => { + const item = document.createElement('a'); + item.className = 'nav-item'; + item.dataset.view = `mod_${mod.id}`; + item.dataset.module = mod.id; + item.innerHTML = `${esc(mod.title)}`; + menu.appendChild(item); + + const section = document.createElement('section'); + section.className = 'page-view'; + section.id = `mod_${mod.id}`; + section.innerHTML = ` + +
`; + main.appendChild(section); + + section.querySelector('[data-reload]').addEventListener('click', () => loadModule(mod, true)); + }); + }); + } + + async function loadModule(mod, force = false) { + if (loadedModules.has(mod.id) && !force) return; + loadedModules.add(mod.id); + + const host = $(`panels_${mod.id}`); + host.innerHTML = mod.panels.map((p) => ` +
+

${esc(p.title)}

+
Loading…
+
`).join(''); + + await Promise.all(mod.panels.map(async (p) => { + const body = host.querySelector(`[data-panel="${cssEscape(p.path)}"] .panel-body`); + try { + const payload = await api(p.path); + renderPayload(body, payload); + } catch (err) { + if (handleApiError(err, mod.id)) return; + body.innerHTML = `
${esc(err.message)}
`; + } + })); + } + + function cssEscape(value) { + return String(value).replace(/["\\]/g, '\\$&'); + } + + // Renders whatever the endpoint returned: scalars become KPI tiles, arrays + // of objects become tables, and nested objects recurse under their key. + function renderPayload(host, payload, depth = 0) { + host.innerHTML = ''; + host.appendChild(buildNode(payload, depth)); + } + + function buildNode(payload, depth) { + const frag = document.createDocumentFragment(); + + if (payload === null || payload === undefined || payload === '') { + frag.appendChild(msgNode('No data returned.')); + return frag; + } + + if (Array.isArray(payload)) { + frag.appendChild(payload.length ? buildTable(payload) : msgNode('No records.')); + return frag; + } + + if (typeof payload !== 'object') { + frag.appendChild(msgNode(String(payload))); + return frag; + } + + const scalars = []; + const nested = []; + Object.entries(payload).forEach(([key, value]) => { + if (value !== null && typeof value === 'object') nested.push([key, value]); + else scalars.push([key, value]); + }); + + if (scalars.length) { + const grid = document.createElement('div'); + grid.className = 'kpi-tiles'; + grid.innerHTML = scalars.map(([k, v]) => ` +
+
${esc(formatValue(v, k))}
+
${esc(humanize(k))}
+
`).join(''); + frag.appendChild(grid); + } + + nested.forEach(([key, value]) => { + const wrap = document.createElement('div'); + wrap.className = 'sub-panel'; + const heading = document.createElement('h4'); + heading.className = 'sub-panel-title'; + heading.textContent = humanize(key); + wrap.appendChild(heading); + wrap.appendChild(buildNode(value, depth + 1)); + frag.appendChild(wrap); + }); + + return frag; + } + + function buildTable(rows) { + const objects = rows.every((r) => r && typeof r === 'object' && !Array.isArray(r)); + if (!objects) { + const list = document.createElement('div'); + list.className = 'mini-list'; + list.innerHTML = rows.map((r) => `
${esc(String(r))}
`).join(''); + return list; + } + + const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))] + .filter((c) => !/token|password|fingerprint/i.test(c)) + .slice(0, 12); + + const wrap = document.createElement('div'); + wrap.className = 'table-responsive'; + wrap.innerHTML = ` + + ${columns.map((c) => ``).join('')} + + ${rows.slice(0, 100).map((row) => `${columns.map((c) => ``).join('')}`).join('')} + +
${esc(humanize(c))}
${cellHtml(c, row[c])}
`; + return wrap; + } + + function cellHtml(column, value) { + if (value === null || value === undefined || value === '') return '—'; + if (typeof value === 'object') return `${esc(JSON.stringify(value).slice(0, 60))}`; + if (/status|type|state|result/i.test(column)) { + return `${esc(labelStatus(value))}`; + } + return esc(formatValue(value, column)); + } + + function formatValue(value, column = '') { + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + if (value === null || value === undefined || value === '') return '—'; + const str = String(value); + if (/^\d{4}-\d{2}-\d{2}[ T]/.test(str)) return fmtDate(str); + if (/(price|fare|amount|revenue|earning|balance|payout|commission|total_paid)/i.test(column) && !isNaN(Number(str))) { + return fmtMoney(str); + } + // Identifiers, phones and codes are digit strings that must never be + // grouped with thousand separators — "0790000000" is not 790,000,000. + const isIdentifier = /(^|_)(id|phone|code|number|otp|year|zip|lat|lng|latitude|longitude)($|_)/i.test(column); + if (!isIdentifier && !str.startsWith('0') && /^-?\d+(\.\d+)?$/.test(str) && str.length < 12) { + return fmtNum(str); + } + return str; + } + + function humanize(key) { + return String(key) + .replace(/[_-]+/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\b\w/g, (m) => m.toUpperCase()) + .trim(); + } + + function msgNode(text) { + const node = document.createElement('div'); + node.className = 'table-msg'; + node.textContent = text; + return node; + } + + // ── Diagnostics ────────────────────────────────────────────────────────── + // Prints exactly what the server replies for every endpoint the console + // uses, so a blank dashboard can be traced to a status code instead of a + // guess: 404 = wrong API base, 403 = role/device rejection, 401 = token. + const PROBES = [ + ['Dashboard stats', '/Admin/dashbord.php'], + ['Rides', '/Admin/rides/get_rides_by_status.php?status=All'], + ['Captains', '/Admin/AdminCaptain/get.php?page=1'], + ['Passengers', '/Admin/getPassengerDetails.php'], + ['Pending approvals', '/Admin/Staff/pending.php'], + ['Rides per month', '/Admin/AdminRide/getRidesPerMonth.php'], + ]; + + async function runDiagnostics() { + const out = el.diagnosticsOutput; + if (!out) return; + busy(el.runDiagnosticsBtn, true, 'Running…'); + + const lines = [ + `Siro Admin diagnostics — ${new Date().toISOString()}`, + `Page origin : ${location.origin}`, + `API base : ${API_BASE}`, + `Fingerprint : ${deviceFingerprint.slice(0, 20)}…`, + `Token : ${session?.jwt ? 'present (role ' + session.role + ')' : 'MISSING — not signed in'}`, + '─'.repeat(64), + ]; + + for (const [name, path] of PROBES) { + const url = API_BASE + path; + const started = performance.now(); + try { + const res = await fetch(url, { + headers: { + 'X-Device-FP': deviceFingerprint, + ...(session?.jwt ? { Authorization: `Bearer ${session.jwt}` } : {}), + }, + }); + const text = await res.text(); + const ms = Math.round(performance.now() - started); + lines.push( + `${res.ok ? '✔' : '✘'} ${name}`, + ` ${url}`, + ` HTTP ${res.status} ${res.statusText} · ${ms}ms · ${text.length} bytes`, + ` ${collapse(text).slice(0, 400)}`, + '' + ); + } catch (err) { + lines.push( + `✘ ${name}`, + ` ${url}`, + ` NETWORK FAILURE — ${err.message}`, + ' (blocked by CORS, DNS, mixed content, or the host is unreachable)', + '' + ); + } + } + + out.textContent = lines.join('\n'); + busy(el.runDiagnosticsBtn, false, 'Run diagnostics'); + } + + function collapse(text) { + return String(text).replace(/\s+/g, ' ').trim() || '(empty response body)'; + } + + function setupDiagnostics() { + if (!el.apiBaseSelect) return; + el.apiBaseSelect.innerHTML = API_CANDIDATES + .map((c) => ``) + .join('') + ''; + + const known = API_CANDIDATES.some((c) => c.value === API_BASE); + el.apiBaseSelect.value = known ? API_BASE : '__custom__'; + el.apiBaseCustom.value = known ? '' : API_BASE; + el.apiBaseCustom.hidden = known; + + el.apiBaseSelect.addEventListener('change', () => { + el.apiBaseCustom.hidden = el.apiBaseSelect.value !== '__custom__'; + }); + + el.saveApiBaseBtn.addEventListener('click', () => { + const chosen = el.apiBaseSelect.value === '__custom__' + ? el.apiBaseCustom.value.trim().replace(/\/$/, '') + : el.apiBaseSelect.value; + if (!chosen) return; + API_BASE = chosen; + localStorage.setItem(API_BASE_KEY, chosen); + toast(`API base set to ${chosen}. Reloading data…`, 'success'); + renderSessionInfo(); + if (session) loadEverything(); + }); + + el.runDiagnosticsBtn.addEventListener('click', runDiagnostics); + el.copyDiagnosticsBtn.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(el.diagnosticsOutput.textContent); + toast('Diagnostics report copied.', 'success'); + } catch { + toast('Copy failed — select the text manually.', 'warning'); + } + }); + } + function showRideDetails(r) { const body = $('modalBodyContent'); const rows = [ @@ -787,6 +1204,9 @@ v.classList.toggle('active', v.id === item.dataset.view)); if (window.innerWidth <= 992) el.sidebar.classList.remove('open'); redrawCharts(); + + const mod = MODULES.find((m) => m.id === item.dataset.module); + if (mod && session) loadModule(mod); }); }); el.toggleSidebar?.addEventListener('click', () => el.sidebar.classList.toggle('open'));