Update: 2026-07-25 01:16:32

This commit is contained in:
Hamza-Ayed
2026-07-25 01:16:33 +03:00
parent eda7018434
commit 0d9095fd3f
3 changed files with 531 additions and 1 deletions
+83
View File
@@ -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;
}
+27
View File
@@ -470,8 +470,35 @@
</div>
<div class="card">
<div class="card-header"><h3 class="card-title">Current session</h3></div>
<div class="mini-list" id="sessionInfo"></div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">API endpoint</h3>
</div>
<p class="card-note">
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.
</p>
<div class="api-base-row">
<select class="select-input" id="apiBaseSelect"></select>
<input type="text" class="form-input" id="apiBaseCustom" placeholder="https://host/backend" hidden>
<button class="btn btn-primary btn-sm" id="saveApiBaseBtn"><i class="ph ph-check"></i> Apply</button>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">Server diagnostics</h3>
<div style="display:flex; gap:0.5rem;">
<button class="btn btn-secondary btn-sm" id="copyDiagnosticsBtn"><i class="ph ph-copy"></i> Copy</button>
<button class="btn btn-secondary btn-sm" id="runDiagnosticsBtn"><i class="ph ph-play"></i> <span>Run diagnostics</span></button>
</div>
</div>
<pre class="diagnostics" id="diagnosticsOutput">Press “Run diagnostics” to call every endpoint and print the raw server reply.</pre>
</div>
</section>
</main>
+421 -1
View File
@@ -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 @@
`<div class="kv-row"><span>${k}</span><strong>${esc(String(v))}</strong></div>`).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 = `<i class="ph ${mod.icon}"></i><span>${esc(mod.title)}</span>`;
menu.appendChild(item);
const section = document.createElement('section');
section.className = 'page-view';
section.id = `mod_${mod.id}`;
section.innerHTML = `
<div class="page-header">
<div class="page-title">
<h1>${esc(mod.title)}</h1>
<p>${esc(mod.subtitle)}</p>
</div>
<div class="page-actions">
<button class="btn btn-secondary btn-sm" data-reload="${mod.id}">
<i class="ph ph-arrows-clockwise"></i> Reload
</button>
</div>
</div>
<div class="module-panels" id="panels_${mod.id}"></div>`;
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) => `
<div class="card" data-panel="${esc(p.path)}">
<div class="card-header"><h3 class="card-title">${esc(p.title)}</h3></div>
<div class="panel-body"><div class="table-msg">Loading…</div></div>
</div>`).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 = `<div class="table-msg is-error">${esc(err.message)}</div>`;
}
}));
}
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]) => `
<div class="kpi-tile">
<div class="kpi-tile-value">${esc(formatValue(v, k))}</div>
<div class="kpi-tile-label">${esc(humanize(k))}</div>
</div>`).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) => `<div class="kv-row"><span>${esc(String(r))}</span></div>`).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 = `
<table class="data-table">
<thead><tr>${columns.map((c) => `<th>${esc(humanize(c))}</th>`).join('')}</tr></thead>
<tbody>
${rows.slice(0, 100).map((row) => `<tr>${columns.map((c) => `<td>${cellHtml(c, row[c])}</td>`).join('')}</tr>`).join('')}
</tbody>
</table>`;
return wrap;
}
function cellHtml(column, value) {
if (value === null || value === undefined || value === '') return '<span class="stamp">—</span>';
if (typeof value === 'object') return `<span class="stamp">${esc(JSON.stringify(value).slice(0, 60))}</span>`;
if (/status|type|state|result/i.test(column)) {
return `<span class="badge ${badgeClass(value)}">${esc(labelStatus(value))}</span>`;
}
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) => `<option value="${esc(c.value)}">${esc(c.label)}</option>`)
.join('') + '<option value="__custom__">Custom…</option>';
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'));