Update: 2026-07-25 01:16:32
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user