%PDF- %PDF-
| Direktori : /var/www/projetos/suporte.iigd.com.br/js/modules/Debug/ |
| Current File : /var/www/projetos/suporte.iigd.com.br/js/modules/Debug/Debug.js |
/**
* ---------------------------------------------------------------------
*
* GLPI - Gestionnaire Libre de Parc Informatique
*
* http://glpi-project.org
*
* @copyright 2015-2024 Teclib' and contributors.
* @copyright 2003-2014 by the INDEPNET Development Team.
* @licence https://www.gnu.org/licenses/gpl-3.0.html
*
* ---------------------------------------------------------------------
*
* LICENSE
*
* This file is part of GLPI.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* ---------------------------------------------------------------------
*/
/* global copyTextToClipboard, escapeMarkupText, hexToRgb, initSortableTable, luminance */
/**
* @typedef ProfilerSection
* @property {string} id
* @property {string|null} parent_id
* @property {string} category
* @property {string} name
* @property {number} start
* @property {number} end
*/
/**
* @typedef Profile
* @property {string} id
* @property {string} parent_id
* @property {{
* execution_time: number,
* memory_usage: number,
* memory_peak: number,
* memory_limit: number,
* }} server_performance
* @property {{
* total_requests: number,
* total_duration: number,
* queries: {
* request_id: string,
* num: number,
* query: string,
* time: number,
* rows: number,
* warnings: string[],
* errors: string[],
* }
* }} sql
* @property {Object.<string, any>} globals
* @property {ProfilerSection[]} [profiler]
*/
/**
* @typedef AJAXRequestData
* @property {string} id
* @property {{}|null} data
* @property {string} url
* @property {{}|null} server_global
* @property {string} type
* @property {Date} start
* @property {number} time
* @property {number} status
* @property {string} status_type
* @property {Profile|null} profile
*/
/**
* @typedef ClientTimingData
* @property {string} type
* @property {string} name
* @property {number} start
* @property {number} end
* @property {Object.<string, {
* x: number,
* y: number,
* width: number,
* height: number
* }>} bounds
* @property {{
* queued: Array,
* redirect: Array,
* fetch: Array,
* dns: Array,
* connection: Array,
* initial_connection: Array,
* ssl: Array,
* request: Array,
* response: Array,
* }} sections
*/
window.GLPI = window.GLPI || {};
window.GLPI.Debug = new class Debug {
constructor() {
/**
* @type {AJAXRequestData[]}
*/
this.ajax_requests = [];
/**
* @type {Profile|null}
*/
this.initial_request = null;
this.widgets = [];
this.TIMING_COLORS = {
queued: '#808080',
redirect: '#00aaaa',
fetch: '#004400',
dns: '#00cc88',
connection: '#ffaa00',
initial_connection: '#ffaa88',
ssl: '#cc00cc',
request: '#00aa00',
response: '#0000ee',
};
this.REQUEST_PATH_LENGTH = 100;
}
init(initial_request) {
this.initial_request = initial_request;
$.each(this.initial_request.sql.queries, (i, query) => {
this.initial_request.sql.queries[i].query = this.cleanSQLQuery(query.query);
});
this.refreshWidgetButtons();
$(document).ajaxSend((event, xhr, settings) => {
// If the request is going to the debug AJAX endpoint, don't do anything
if (settings.url.indexOf('ajax/debug.php') !== -1) {
return;
}
const ajax_id = Math.random().toString(16).slice(2);
// Tag the request with an id to identify it on the server side
xhr.setRequestHeader('X-Glpi-Ajax-ID', ajax_id);
// Need to set the header here too so it is accessible in the ajaxComplete event
xhr.headers = xhr.headers || {};
xhr.headers['X-Glpi-Ajax-ID'] = ajax_id;
const parent_id = $('html').attr('data-glpi-request-id');
if (parent_id !== undefined) {
xhr.setRequestHeader('X-Glpi-Ajax-Parent-ID', parent_id);
}
let data = settings.data;
if (settings.type !== 'POST' && data === undefined) {
// get data from query string
data = {};
const query_string = settings.url.split('?')[1];
if (query_string !== undefined) {
query_string.split('&').forEach((pair) => {
const [key, value] = pair.split('=');
data[key] = value;
});
}
} else if (typeof data === 'string') {
// Post data is a URI encoded string similar to a query string. Values may be JSON strings
// so we need to parse it and convert it back to an object
const data_object = {};
data.split('&').forEach((pair) => {
const [key, value] = pair.split('=');
data_object[key] = decodeURIComponent(value);
// try parsing the value as JSON
try {
data_object[key] = JSON.parse(data_object[key]);
} catch (e) {
// ignore
}
});
data = data_object;
}
this.ajax_requests.push({
'id': ajax_id,
'status': '...',
'status_type': 'info',
'type': settings.type,
'data': data,
'url': settings.url,
'start': event.timeStamp,
});
this.refreshWidgetButtons();
});
$(document).ajaxComplete((event, xhr, settings) => {
// If the request is going to the debug AJAX endpoint, don't do anything
if (settings.url.indexOf('ajax/debug.php') !== -1) {
return;
}
if (xhr.headers === undefined) {
return;
}
const ajax_id = xhr.headers['X-Glpi-Ajax-ID'];
if (ajax_id !== undefined) {
const ajax_request = this.ajax_requests.find((request) => request.id === ajax_id);
if (ajax_request !== undefined) {
ajax_request.status = xhr.status;
ajax_request.time = new Date() - ajax_request.start;
ajax_request.status_type = xhr.status >= 200 && xhr.status < 300 ? 'success' : 'danger';
// Ask the server for the debug information it saved for this request
this.requestAjaxDebugData(ajax_id);
}
}
this.refreshWidgetButtons();
});
$('#debug-toolbar').on('click', '.debug-toolbar-widget', (e) => {
const widget_id = $(e.currentTarget).attr('data-glpi-debug-widget-id');
this.showWidget(widget_id);
this.toggleExtraContentArea(true);
});
const resize_handle = $('#debug-toolbar > .resize-handle');
const expanded_content_area = $('#debug-toolbar-expanded-content');
let is_dragging = false;
resize_handle.on('mousedown', (e) => {
if (e.buttons === 1) {
is_dragging = true;
e.preventDefault();
}
});
$(document).on('mousemove', (e) => {
if (is_dragging && e.buttons === 1) {
const page_height = $(window).height();
let new_height = page_height - e.pageY;
new_height = Math.max(new_height, 200);
expanded_content_area.css('height', `${new_height}px`);
}
});
$(document).on('mouseup', () => {
is_dragging = false;
});
expanded_content_area.on('click', 'button.request-link', (e) => {
const request_id = $(e.currentTarget).text();
// Show the requests widget and select the request
this.showWidget('requests');
// Find the request in the table and select it
const request_row = $(`#debug-toolbar-expanded-content #debug-requests-table tr[data-request-id="${request_id}"]`);
if (request_row.length > 0) {
request_row[0].scrollIntoView();
request_row.click();
}
});
}
requestAjaxDebugData(ajax_id, reload_widget = false) {
const ajax_request = this.ajax_requests.find((request) => request.id === ajax_id);
$.ajax({
url: CFG_GLPI.root_doc + '/ajax/debug.php',
data: {
'ajax_id': ajax_id,
}
}).done((data) => {
ajax_request.profile = data;
$.each(ajax_request.profile.sql.queries, (i, query) => {
ajax_request.profile.sql.queries[i].query = this.cleanSQLQuery(query.query);
});
const content_area = $('#debug-toolbar-expanded-content');
if (content_area.data('active-widget') !== undefined) {
this.showWidget(content_area.data('active-widget'), true);
}
// Move server global to the profile
if (ajax_request.server_global !== undefined) {
ajax_request.profile.globals['server'] = ajax_request.server_global;
}
// Move the data to either the get or post global depending on the request type
if (ajax_request.type === 'POST') {
ajax_request.profile.globals['post'] = ajax_request.data;
} else {
ajax_request.profile.globals['get'] = ajax_request.data;
}
this.refreshWidgetButtons();
if (reload_widget) {
// reload active widget
this.showWidget(content_area.data('active-widget'), true);
}
});
}
cleanSQLQuery(query) {
const newline_keywords = ['UNION', 'FROM', 'WHERE', 'INNER JOIN', 'LEFT JOIN', 'ORDER BY', 'SORT'];
const post_newline_keywords = ['UNION'];
let clean_query = '';
window.CodeMirror.runMode(query, 'text/x-sql', (text, style) => {
text = escapeMarkupText(text);
if (style !== null && style !== undefined) {
if (newline_keywords.includes(text.toUpperCase())) {
clean_query += '</br>';
}
clean_query += `<span class="cm-${style.replace(' ', '')}">${text}</span>`;
if (post_newline_keywords.includes(text.toUpperCase())) {
clean_query += '</br>';
}
} else {
clean_query += text;
}
});
return clean_query;
}
getCombinedSQLData() {
const sql_data = {
total_requests: 0,
total_duration: 0,
queries: {}
};
sql_data.queries[this.initial_request.id] = this.initial_request.sql.queries;
this.ajax_requests.forEach((request) => {
if (request.profile && request.profile.sql !== undefined) {
sql_data.queries[request.id] = request.profile.sql.queries;
}
});
$.each(sql_data.queries, (request_id, data) => {
// update the total counters
data.forEach((query) => {
sql_data.total_requests += 1;
sql_data.total_duration += parseInt(query['time']);
});
});
return sql_data;
}
showDebugToolbar() {
$('.debug-logo').prop('disabled', true);
$('.debug-toolbar-content').removeClass('d-none');
$('#debug-toolbar').addClass('w-100').css('width', null);
$('body').removeClass('debug-folded');
}
hideDebugToolbar() {
$('.debug-logo').prop('disabled', false);
$('.debug-toolbar-content').addClass('d-none');
$('#debug-toolbar-expanded-content').addClass('d-none');
$('#debug-toolbar').removeClass('w-100').css('width', 'fit-content');
$('body').addClass('debug-folded');
}
toggleExtraContentArea(force_show = false) {
const content_area = $('#debug-toolbar-expanded-content');
const toggle_icon = $('#debug-toolbar .debug-toolbar-controls button[name="toggle_content_area"] i');
if (content_area.hasClass('d-none') || force_show) {
content_area.removeClass('d-none');
toggle_icon.removeClass('ti-square-arrow-up').addClass('ti-square-arrow-down');
} else {
content_area.addClass('d-none');
toggle_icon.removeClass('ti-square-arrow-down').addClass('ti-square-arrow-up');
}
}
getProfile(request_id) {
if (request_id === this.initial_request.id) {
return this.initial_request;
}
return this.ajax_requests.find((request) => request.id === request_id).profile;
}
getWidgetButton(widget_id) {
return $(`#debug-toolbar .debug-toolbar-widgets li[data-glpi-debug-widget-id="${widget_id}"]`);
}
refreshWidgetButtons() {
// Server performance
const server_perf = this.initial_request.server_performance;
const memory_usage = +(server_perf.memory_usage / 1024 / 1024).toFixed(2);
const server_performance_button_label = `${server_perf.execution_time} <span class="text-muted"> ms using </span> ${memory_usage} <span class="text-muted"> MiB </span>`;
this.getWidgetButton('server_performance').find('.debug-text').html(server_performance_button_label);
// Database performance
const sql_data = this.getCombinedSQLData();
const database_button_label = `${sql_data.total_requests} <span class="text-muted"> requests </span>`;
this.getWidgetButton('sql').find('.debug-text').html(database_button_label);
// Requests
this.getWidgetButton('requests').find('.debug-text').html(`${this.ajax_requests.length} <span class="text-muted"> requests </span>`);
// Client performances
const dom_timing = +window.performance.getEntriesByType('navigation')[0].domComplete.toFixed(2);
const client_performance_button_label = `${dom_timing} <span class="text-muted"> ms </span>`;
this.getWidgetButton('client_performance').find('.debug-text').html(client_performance_button_label);
}
showWidget(widget_id, refresh = false, content_area = undefined, data = {}) {
if (content_area === undefined) {
content_area = $('#debug-toolbar-expanded-content');
// if there is a button in the toolbar for this widget, make it active
const widget_button = this.getWidgetButton(widget_id);
if (widget_button.length > 0) {
$('#debug-toolbar .debug-toolbar-widgets .debug-toolbar-widget').removeClass('active');
widget_button.addClass('active');
}
}
content_area.data('active-widget', widget_id);
$.each(data, (key, value) => {
content_area.data(key, value);
});
switch (widget_id) {
case 'server_performance':
this.showServerPerformance(content_area, refresh);
break;
case 'sql':
this.showSQLRequests(content_area, refresh);
break;
case 'globals':
this.showGlobals(content_area);
break;
case 'client_performance':
this.showClientPerformance(content_area, refresh);
break;
case 'profiler':
this.showProfiler(content_area, refresh);
break;
case 'requests':
this.showRequests(content_area, refresh);
break;
case 'request_summary':
this.showRequestSummary(content_area);
break;
default:
content_area.empty();
content_area.append(`<div class="alert alert-danger"><h1>Content for widget ${widget_id} not found</h1></div>`);
}
}
showServerPerformance(content_area, refresh = false) {
if (!refresh) {
content_area.empty();
content_area.append(`
<div class="py-2 px-3 col-xxl-7 col-xl-9 col-12">
<h2 class="mb-3">Server performance</h2>
<div class="datagrid"></div>
</div>
`);
}
const server_perf = this.initial_request.server_performance;
const memory_usage = (server_perf.memory_usage / 1024 / 1024).toFixed(2);
const memory_peak = (server_perf.memory_peak / 1024 / 1024).toFixed(2);
const memory_limit = (server_perf.memory_limit / 1024 / 1024).toFixed(2);
let total_execution_time = this.initial_request.server_performance.execution_time;
this.ajax_requests.forEach((request) => {
if (request.profile) {
total_execution_time += request.profile.server_performance.execution_time;
}
});
total_execution_time = total_execution_time.toFixed(2);
content_area.find('.datagrid').empty().append(`
<div class="datagrid-item">
<div class="datagrid-title">Initial Execution Time</div>
<div class="datagrid-content">${+this.initial_request.server_performance.execution_time} ms</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Total Execution Time</div>
<div class="datagrid-content">${+total_execution_time} ms</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Memory Usage</div>
<div class="datagrid-content h-100 col-8">${+memory_usage} MiB / ${+memory_limit} MiB</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Memory Peak</div>
<div class="datagrid-content">${+memory_peak} MiB / ${+memory_limit} MiB</div>
</div>
`);
}
showSQLRequests(content_area, refresh = false) {
const filtered_request_id = content_area.data('request_id');
if (filtered_request_id !== undefined && this.getProfile(filtered_request_id) === undefined) {
this.showMissingRequestData(content_area, content_area.data('request_id'));
return;
}
if (!refresh) {
content_area.empty();
content_area.append(`
<div class="overflow-auto py-2 px-3">
<h2 class="mb-3"></h2>
<table id="debug-sql-request-table" class="table card-table">
<thead>
<tr>
${filtered_request_id === undefined ? '<th>Request ID</th>' : ''}
<th>Number</th><th>Query</th><th>Time</th><th>Rows</th><th>Warnings</th><th>Errors</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
`);
initSortableTable('debug-sql-request-table');
}
const sql_table = content_area.find('table').first();
const sql_table_body = sql_table.find('tbody').first();
// get all the request IDs present in the SQL data (first column values)
let request_ids_present = new Set();
sql_table_body.find('tr td:first-child').each((index, cell) => {
request_ids_present.add($(cell).text());
});
const sql_data = this.getCombinedSQLData();
let rows_to_append = '';
$.each(sql_data['queries'], (request_id, queries) => {
if (request_ids_present.has(request_id) ||
(filtered_request_id !== undefined && filtered_request_id !== request_id)) {
return;
}
queries.forEach((query) => {
//Note: keep the query cell as a single line, or it will ruin the formatting of the contents
rows_to_append += `
<tr>
${filtered_request_id === undefined ? `<td><button class="btn btn-link request-link">${request_id}</button></td>` : ''}
<td>${query['num']}</td>
<td>
<div class="d-flex align-items-start" style="max-width: 50vw;">
<div style="max-width: 50vw; white-space: break-spaces;" class="w-100"><code class="d-block cm-s-default border-0">${query['query']}</code></div>
<button type="button" class="ms-1 copy-code btn btn-sm btn-ghost-secondary" title="Copy query to clipboard">
<i class="ti ti-clipboard-copy"></i>
</button>
</div>
</td>
<td data-value-unit="ms">${query['time']} ms</td>
<td>${query['rows']}</td>
<td>${escapeMarkupText(query['warnings'])}</td>
<td>${escapeMarkupText(query['errors'])}</td>
</tr>
`;
});
});
sql_table_body.append(rows_to_append);
$(".copy-code").on('click', function () {
// copy content of code block in clipboard
const code = $(this).parent().find('code');
copyTextToClipboard(code.text());
// change temporary the button icon to a check then after a while return to the original icon
const icon = $(this).find('i');
icon.removeClass('ti-clipboard-copy').addClass('ti-check');
setTimeout(() => {
icon.removeClass('ti-check').addClass('ti-clipboard-copy');
}, 1000);
});
if (filtered_request_id !== undefined) {
let total_requests = 0;
let total_duration = 0;
$.each(sql_data['queries'], (request_id, queries) => {
if (request_id === filtered_request_id) {
total_requests += queries.length;
queries.forEach((query) => {
total_duration += parseFloat(query['time']);
});
}
});
content_area.find('h2').first()
.text(`${total_requests} Queries took ${total_duration} ms`);
} else {
content_area.find('h2').first()
.text(`${sql_data.total_requests} Queries took ${sql_data.total_duration} ms`);
}
if (sql_table.data('sort')) {
sql_table.find('thead th').eq(sql_table.data('sort')).click().click();
}
}
showGlobals(content_area) {
const appendGlobals = (data, container) => {
if (data === undefined || data === null) {
container.append('Empty array');
return;
}
let data_string = data;
try {
data_string = JSON.stringify(data, null, ' ');
} catch (e) {
if (typeof data !== 'string') {
container.append('Empty array');
return;
}
}
const editor = window.CodeMirror(container.get(0), {
value: data_string,
mode: 'application/json',
lineNumbers: true,
readOnly: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'],
});
container.data('editor', editor);
};
const rand = Math.floor(Math.random() * 1000000);
content_area.empty();
content_area.append(`
<div>
<div id="debugpanel${rand}" class="container-fluid card p-0 border-top-0" style="min-width: 400px; max-width: 90vw">
<ul class="nav nav-pills" data-bs-toggle="tabs">
<li class="nav-item"><a class="nav-link active" data-bs-toggle="tab" href="#debugpost${rand}">POST</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#debugget${rand}">GET</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#debugsession${rand}">SESSION</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#debugserver${rand}">SERVER</a></li>
</ul>
<div class="card-body overflow-auto p-1">
<div class="tab-content">
<div id="debugpost${rand}" class="cm-s-default tab-pane active"></div>
<div id="debugget${rand}" class="cm-s-default tab-pane"></div>
<div id="debugsession${rand}" class="cm-s-default tab-pane"></div>
<div id="debugserver${rand}" class="cm-s-default tab-pane"></div>
</div>
</div>
</div>
</div>
`);
const selected_request_id = content_area.data('request_id');
const matching_profile = this.getProfile(selected_request_id);
if (matching_profile === undefined) {
this.showMissingRequestData(content_area, content_area.data('request_id'));
return;
}
const globals = matching_profile.globals;
appendGlobals(globals['post'], content_area.find(`#debugpost${rand}`));
appendGlobals(globals['get'], content_area.find(`#debugget${rand}`));
appendGlobals(globals['session'], content_area.find(`#debugsession${rand}`));
appendGlobals(globals['server'], content_area.find(`#debugserver${rand}`));
content_area.on('shown.bs.tab', 'a[data-bs-toggle="tab"]', (e) => {
const target = $(e.target).attr('href');
const target_el = content_area.find(target);
const previously_shown = target_el.data('previously_shown') || false;
const editor = target_el.data('editor');
if (!previously_shown && editor) {
editor.refresh();
setTimeout(() => {
// Stupid solution to fold all levels except the first one.
// foldCode(0), would fold the first level only and doesn't handle nested levels.
const total_lines = editor.lineCount();
// Must start from the bottom, otherwise it doesn't fold parent levels
for (let i = total_lines - 1; i > 1; i--) {
editor.foldCode(window.CodeMirror.Pos(i, 0));
}
}, 100);
}
target_el.data('previously_shown', true);
});
// trigger shown.bs.tab on the first tab manually since the event is not triggered on page load
content_area.find('a[data-bs-toggle="tab"]').first().trigger('shown.bs.tab');
}
showClientPerformance(content_area, refresh = false) {
if (!refresh) {
content_area.empty();
}
const perf = window.performance;
const nav_timings = window.performance.getEntriesByType('navigation')[0];
const paint_timings = window.performance.getEntriesByType('paint');
const resource_timings = window.performance.getEntriesByType('resource');
let paint_timing = paint_timings.filter((timing) => timing.name === 'first-paint');
let paint_timing_label = 'Time to first paint';
if (paint_timing.length === 0) {
// Firefox doesn't have first-paint for whatever reason
paint_timing = paint_timings.filter((timing) => timing.name === 'first-contentful-paint');
paint_timing_label = 'Time to first contentful paint';
}
const time_to_first_paint = paint_timing.length > 0 ? paint_timing[0].startTime : -1;
const time_to_dom_interactive = nav_timings.domInteractive;
const time_to_dom_complete = nav_timings.domComplete;
const total_resources = resource_timings.length;
let total_resources_size = resource_timings.reduce((total, timing) => total + timing.transferSize, 0);
total_resources_size = total_resources_size / 1024 / 1024;
content_area.append(`
<div class="py-2 px-3 col-xxl-7 col-xl-9 col-12">
<h2 class="mb-3">Client performance</h2>
<h3 class="mb-2">Timings</h3>
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">${paint_timing_label}</div>
<div class="datagrid-content">${+time_to_first_paint.toFixed(2)} ms</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Time to DOM interactive</div>
<div class="datagrid-content">${+time_to_dom_interactive.toFixed(2)} ms</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Time to DOM complete</div>
<div class="datagrid-content">${+time_to_dom_complete.toFixed(2)} ms</div>
</div>
</div>
<h3 class="mt-3 mb-2">Resource Loading</h3>
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">Total resources</div>
<div class="datagrid-content">${total_resources}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Total resources size</div>
<div class="datagrid-content">${+total_resources_size.toFixed(2)} MiB</div>
</div>
<!-- Keep empty item at the end to align with previous grid -->
<div class="datagrid-item"></div>
</div>
</div>
`);
if (perf.memory != undefined) {
const heap_limit = perf.memory.jsHeapSizeLimit / 1024 / 1024;
const used_heap = perf.memory.usedJSHeapSize / 1024 / 1024;
const total_heap = perf.memory.totalJSHeapSize / 1024 / 1024;
// Non-standard feature supported by Chrome
content_area.find('.datagrid:last').append(`
<h3 class="mt-3 mb-2">Memory</h3>
<div class="datagrid">
<div class="datagrid-item">
<div class="datagrid-title">Used JS Heap</div>
<div class="datagrid-content">${+used_heap.toFixed(2)}</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">Total JS Heap</div>
<div class="datagrid-content">${+total_heap.toFixed(2)} MiB</div>
</div>
<div class="datagrid-item">
<div class="datagrid-title">JS Heap Limit</div>
<div class="datagrid-content">${+heap_limit.toFixed(2)} MiB</div>
</div>
</div>
`);
}
}
getProfilerCategoryColor(category) {
const predefined_colors = {
core: '#526dad',
db: '#9252ad',
twig: '#64ad52',
plugins: '#a077a6',
};
let bg_color = '';
if (predefined_colors[category] !== undefined) {
bg_color = predefined_colors[category];
} else {
let hash = 0;
for (let i = 0; i < category.length; i++) {
hash = category.charCodeAt(i) + ((hash << 5) - hash);
}
let color = '#';
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xFF;
color += ('00' + value.toString(16)).substr(-2);
}
bg_color = color;
}
const rgb = hexToRgb(bg_color);
const text_color = luminance([rgb['r'], rgb['g'], rgb['b']]) > 0.5 ? 'var(--dark)' : 'var(--light)';
return {
bg_color: bg_color,
text_color: text_color
};
}
getProfilerTable(parent_id, profiler_sections, nest_level = 0, parent_duration = 0) {
let table = `
<table class="table table-striped card-table">
<thead>
<tr>
${'<th style="min-width: 2rem"></th>'.repeat(nest_level)}
<th>Category</th>
<th>Name</th>
<th>Start</th>
<th>End</th>
<th>Duration</th>
<th>Percent of parent</th>
</tr>
</thead>
<tbody>
`;
const col_count = 6 + nest_level;
const top_level_sections = profiler_sections.filter((section) => section.parent_id === parent_id);
top_level_sections.forEach((section) => {
const cat_colors = this.getProfilerCategoryColor(section.category);
const duration = section.end - section.start;
let percent_of_parent = 100;
if (nest_level > 0) {
percent_of_parent = (duration / parent_duration) * 100;
}
percent_of_parent = percent_of_parent.toFixed(2);
table += `
<tr data-profiler-section-id="${section.id}">
${'<td style="min-width: 2rem"></td>'.repeat(nest_level)}
<td>
<span class="category-badge" style='background-color: ${cat_colors.bg_color}; color: ${cat_colors.text_color}'>
${escapeMarkupText(section.category)}
</span>
</td>
<td>${escapeMarkupText(section.name)}</td><td>${section.start}</td><td>${section.end}</td>
<td data-column="duration" data-duration-raw="${duration}">${duration.toFixed(0)} ms</td>
<td>${percent_of_parent}%</td>
</tr>
`;
const children = profiler_sections.filter((child) => child.parent_id === section.id);
if (children.length > 0) {
const children_table = this.getProfilerTable(section.id, profiler_sections, nest_level + 1, duration);
table += `<tr><td colspan="${col_count}">${children_table}</td></tr>`;
}
});
table += '</tbody></table>';
return table;
}
showProfiler(content_area, refresh = false) {
if (!refresh) {
content_area.empty();
}
content_area.append(`
<div>
<label>
Hide near-instant sections (<= 1 ms):
<input type="checkbox" name="hide_instant_sections">
</label>
</div>
`);
const selected_request_id = content_area.data('request_id');
const hide_instant_sections_box = content_area.find('input[name="hide_instant_sections"]');
const hide_instant_sections = content_area.data('profiler_hide_instant_sections') || true;
hide_instant_sections_box.prop('checked', hide_instant_sections);
hide_instant_sections_box.off('change').on('change', (e) => {
const hide = $(e.target).prop('checked');
content_area.data('profiler_hide_instant_sections', hide);
const table_rows = content_area.find('tr');
// Start by un-hiding all rows
table_rows.removeClass('d-none');
if (hide) {
// hide all rows in the table that have the duration column set less than 1 ms
table_rows.each((index, row) => {
const duration_cell = $(row).find('> td[data-column="duration"]');
if (duration_cell.length > 0) {
const duration_value = parseFloat(duration_cell.attr('data-duration-raw'));
if (duration_value <= 1.0) {
$(row).addClass('d-none');
}
}
});
}
// If any table has no visible rows, hide the whole table
content_area.find('table').each((index, table) => {
const table_el = $(table);
const table_parent = table_el.parent();
if (table_el.find('> tbody > tr:not(.d-none)').length === 0) {
table_el.addClass('d-none');
if (table_parent.prop('tagName') === 'TD') {
table_parent.addClass('d-none');
}
} else {
table_el.removeClass('d-none');
if (table_parent.prop('tagName') === 'TD') {
table_parent.removeClass('d-none');
}
}
});
});
const matching_profile = this.getProfile(selected_request_id);
if (matching_profile === undefined) {
this.showMissingRequestData(content_area, selected_request_id);
return;
}
const profiler = matching_profile.profiler || {};
// get profiler entries and sort them by start time
// Logically, child entries should remain under their parent since everything is done synchronously
const profiler_sections = Object.values(profiler).sort((a, b) => a.start - b.start);
content_area.find('> div').append(this.getProfilerTable(null, profiler_sections));
hide_instant_sections_box.trigger('change');
}
showRequests(content_area, refresh = false) {
if (!refresh) {
content_area.empty();
const rand = Math.floor(Math.random() * 1000000);
content_area.append(`
<div class="request-timeline"></div>
<div class="d-flex flex-row h-100 split-panel-h">
<div class="left-panel">
<div class="overflow-auto h-100 me-2">
<table id="debug-requests-table" class="table table-hover mb-1">
<thead>
<tr>
<th>Number</th>
<th style="max-width: 200px; white-space: pre-wrap;">URL</th>
<th>Status</th>
<th>Type</th>
<th>Duration</th>
</tr>
</thead>
<tbody style="white-space: nowrap">
</tbody>
</table>
</div>
</div>
<div class="resize-handle"></div>
<div class="right-panel overflow-auto ms-2 flex-grow-1">
<div id="debugpanel${rand}" class="p-0 mt-n1">
<ul class="nav nav-tabs" data-bs-toggle="tabs">
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-glpi-debug-widget-id="request_summary">Summary</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-glpi-debug-widget-id="sql">SQL</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-glpi-debug-widget-id="globals">Globals</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-glpi-debug-widget-id="profiler">Profiler</button>
</li>
</ul>
<div class="card-body overflow-auto p-1">
<div class="tab-content request-details-content-area">
</div>
</div>
</div>
</div>
</div>
`);
this.showRequestTimeline(content_area.find('.request-timeline').eq(0));
const truncated_pathname = window.location.pathname.substring(0, this.REQUEST_PATH_LENGTH);
const is_truncated = truncated_pathname.length < window.location.pathname.length;
content_area.find('#debug-requests-table tbody').append(`
<tr data-request-id="${this.initial_request.id}" class="cursor-pointer table-active">
<td>0</td>
<td style="max-width: 200px; white-space: pre-wrap;" title="${window.location.pathname}" data-truncated="${is_truncated ? 'true' : 'false'}">${truncated_pathname}</td>
<td>-</td>
<td>${this.initial_request.globals.server['REQUEST_METHOD'] || '-'}</td>
<td>${this.initial_request.server_performance.execution_time} ms</td>
</tr>
`);
if (is_truncated) {
content_area.find(`tr[data-request-id="${this.initial_request.id}"] td[data-truncated="true"]`).append(
`<button type="button" class="ms-1 badge bg-secondary" name="show_full_url">
<i class="ti ti-dots"></i>
</button>`
);
}
const resize_handle = content_area.find('.resize-handle');
// Make the resize handle draggable to resize the left column
let is_dragging = false;
resize_handle.on('mousedown', (e) => {
if (e.buttons === 1) {
is_dragging = true;
e.preventDefault();
}
});
content_area.on('mousemove', (e) => {
if (is_dragging && e.buttons === 1) {
const left_column = content_area.find('> div > div:first-child');
const new_width = e.pageX - left_column.offset().left;
left_column.css('flex', `0 0 ${new_width}px`);
}
});
content_area.on('mouseup', () => {
is_dragging = false;
});
content_area.on('click', 'button[data-glpi-debug-widget-id]', (e) => {
const widget_id = $(e.currentTarget).attr('data-glpi-debug-widget-id');
content_area.data('requests_active_widget', widget_id);
this.showWidget(widget_id, false, content_area.find('.request-details-content-area'), {
request_id: content_area.data('requests_request_id') || this.initial_request.id,
});
});
content_area.on('click', '#debug-requests-table tbody tr', (e) => {
content_area.data('requests_request_id', $(e.currentTarget).attr('data-request-id'));
$(e.currentTarget).addClass('table-active').siblings().removeClass('table-active');
this.showWidget(content_area.data('requests_active_widget') || 'request_summary', false, content_area.find('.request-details-content-area'), {
request_id: content_area.data('requests_request_id') || this.initial_request.id,
});
});
content_area.on('click', 'button[name="show_full_url"]', (e) => {
const btn = $(e.currentTarget);
const td = btn.closest('td');
td.text(td.attr('title'));
btn.hide();
});
if (content_area.data('requests_request_id') === undefined) {
content_area.data('requests_request_id', this.initial_request.id);
}
if (content_area.find('.request-details-content-area').data('request_id') === undefined) {
content_area.find('.request-details-content-area').data('request_id', this.initial_request.id);
}
content_area.find('button[data-glpi-debug-widget-id="request_summary"]').click();
initSortableTable('debug-requests-table');
}
// Add all AJAX requests to the list that aren't already there
this.ajax_requests.forEach((request) => {
const row = content_area.find(`tr[data-request-id="${request.id}"]`);
if (row.length === 0) {
const next_number = content_area.find('#debug-requests-table tbody tr').length;
const truncated_url = request.url.substring(0, this.REQUEST_PATH_LENGTH);
const is_truncated = truncated_url.length < request.url.length;
content_area.find('#debug-requests-table tbody').append(`
<tr data-request-id="${request.id}" class="cursor-pointer">
<td>${next_number}</td>
<td style="max-width: 200px; white-space: pre-wrap;" title="${request.url}" data-truncated="${is_truncated ? 'true' : 'false'}">${truncated_url}</td>
<td>${request.status}</td>
<td>${request.type}</td>
<td data-value-unit="ms">${request.time} ms</td>
</tr>
`);
if (is_truncated) {
if (content_area.find(`tr[data-request-id="${request.id}"] td[data-truncated="true"] button[name="show_full_url"]`).length === 0) {
content_area.find(`tr[data-request-id="${request.id}"] td[data-truncated="true"]`).append(
`<button type="button" class="ms-1 badge bg-secondary" name="show_full_url">
<i class="ti ti-dots"></i>
</button>`
);
}
}
// set the background color of the new row to a pale yellow and fade it out
const new_row = content_area.find(`tr[data-request-id="${request.id}"]`);
new_row.css('background-color', '#FFFF7B80');
setTimeout(() => {
new_row.css('background-color', 'transparent');
}, 2000);
const requests_table = content_area.find('#debug-requests-table');
if (requests_table.data('sort')) {
requests_table.find('thead th').eq(requests_table.data('sort')).click().click();
} else {
// scroll to the bottom of the table
requests_table.parent().scrollTop(requests_table.parent()[0].scrollHeight);
}
} else {
row.find('> td:nth-child(3)').text(request.status);
row.find('> td:nth-child(5)').text(`${request.time} ms`);
}
});
}
showMissingRequestData(content_area, request_id) {
content_area.empty();
content_area.append(`
<div class="alert alert-danger">
<span>No debug data was found for this request immediately after it finished. Some requests like /front/locale.php will never have data as they intentionally close the session.</span>
</div>
<button type="button" class="btn btn-primary" data-request-id="${request_id}"><i class="ti ti-reload"></i>Retry</button>
`);
content_area.find('button').on('click', (e) => {
const btn = $(e.currentTarget);
const request_id = btn.data('request-id');
this.requestAjaxDebugData(request_id, true);
});
}
showRequestSummary(content_area) {
content_area.empty();
const profile = this.getProfile(content_area.data('request_id'));
if (profile === undefined) {
this.showMissingRequestData(content_area, content_area.data('request_id'));
return;
}
const server_perf = profile.server_performance;
const memory_usage = (server_perf.memory_usage / 1024 / 1024).toFixed(2);
const memory_peak = (server_perf.memory_peak / 1024 / 1024).toFixed(2);
const memory_limit = (server_perf.memory_limit / 1024 / 1024).toFixed(2);
let total_execution_time = profile.server_performance.execution_time;
let total_sql_duration = 0;
let total_sql_queries = 0;
$.each(profile.sql['queries'], (i, query) => {
total_sql_queries++;
total_sql_duration += parseFloat(query['time']);
});
content_area.append(`
<h1>Request Summary (${profile.id})</h1>
<table class="table">
<tbody>
<tr>
<td>
Initial Execution Time: ${total_execution_time} ms
</td>
<td>
Memory Usage: ${memory_usage} MiB / ${memory_limit} MiB
<br>
Memory Peak: ${memory_peak} MiB / ${memory_limit} MiB
</td>
</tr>
<tr>
<td>
SQL Requests: ${total_sql_queries}
<br>
SQL Duration: ${total_sql_duration} ms
</td>
</tr>
</tbody>
</table>
`);
}
/**
* Get all timings for requests
*/
getAllRequestTimings() {
const navigation_timings = window.performance.getEntriesByType('navigation')[0];
const resource_timings = window.performance.getEntriesByType('resource');
const timings = [];
timings.push({
type: 'navigation',
name: navigation_timings.name,
start: navigation_timings.startTime,
end: navigation_timings.responseEnd,
bounds: {},
sections: {
queued: [navigation_timings.startTime, navigation_timings.redirectStart],
redirect: [navigation_timings.redirectStart, navigation_timings.redirectEnd],
fetch: [navigation_timings.redirectEnd, navigation_timings.redirectStart],
dns: [navigation_timings.domainLookupStart, navigation_timings.domainLookupEnd],
connection: [navigation_timings.connectStart, navigation_timings.connectEnd],
initial_connection: [navigation_timings.connectStart, navigation_timings.secureConnectionStart],
ssl: [navigation_timings.secureConnectionStart, navigation_timings.connectEnd],
request: [navigation_timings.requestStart, navigation_timings.responseStart], // Mainly waiting for the server to respond
response: [navigation_timings.responseStart, navigation_timings.responseEnd],
}
});
$.each(resource_timings, (i, resource_timing) => {
timings.push({
type: resource_timing.initiatorType,
name: resource_timing.name,
start: resource_timing.startTime,
end: resource_timing.responseEnd,
bounds: {},
sections: {
queued: [resource_timing.startTime, resource_timing.redirectStart !== 0 ? resource_timing.redirectStart : resource_timing.domainLookupStart],
redirect: [resource_timing.redirectStart, resource_timing.redirectEnd],
fetch: [resource_timing.redirectEnd, resource_timing.redirectStart],
dns: [resource_timing.domainLookupStart, resource_timing.domainLookupEnd],
connection: [resource_timing.connectStart, resource_timing.connectEnd],
initial_connection: [resource_timing.connectStart, resource_timing.secureConnectionStart],
ssl: [resource_timing.secureConnectionStart, resource_timing.connectEnd],
request: [resource_timing.requestStart, resource_timing.responseStart], // Mainly waiting for the server to respond
response: [resource_timing.responseStart, resource_timing.responseEnd],
}
});
});
// find the longest duration based on the response end
let longest_duration = 0;
$.each(timings, (i, timing) => {
const response_end = timing.sections.response[1];
if (response_end > longest_duration) {
longest_duration = response_end;
}
});
return {
end_ts: longest_duration,
timings: timings
};
}
showRequestTimeline(content_area) {
content_area.empty();
const timing_data = this.getAllRequestTimings();
const end_ts = timing_data.end_ts;
const timings = timing_data.timings;
const time_origin = window.performance.timeOrigin;
// group timings into sections so that there are no overlaps (based on start and end times)
const sections = [];
const hasOverlap = (section, start, end) => {
// check if the start or end time would fall within any of the timings in the given section
let overlap = false;
$.each(section, (i, timing) => {
if ((start >= timing.start && start <= timing.end) || (end >= timing.start && end <= timing.end)) {
overlap = true;
return false;
}
});
return overlap;
};
$.each(timings, (i, timing) => {
let section = null;
$.each(sections, (i, s) => {
if (!hasOverlap(s, timing.start, timing.end)) {
section = s;
return false;
}
});
if (section === null) {
section = [timing];
sections.push(section);
} else {
section.push(timing);
}
});
const TIMELINE_REFRESH_RATE = 10; // 10 FPS
const DIVIDER_WIDTH = 150;
const ROW_HEIGHT = 4;
const ROW_MARGIN = 2;
content_area.append(`
<canvas class="d-none" height="${(sections.length * (ROW_HEIGHT + ROW_MARGIN)) + 12}"></canvas>
`);
const canvas_el = content_area.find('canvas').eq(0);
content_area.closest('#debug-toolbar').on('keyup', (e) => {
e.preventDefault();
e.stopPropagation();
if (e.keyCode === 84) { // 't'
canvas_el.toggleClass('d-none');
}
});
/**
* @type {CanvasRenderingContext2D}
*/
const ctx = canvas_el[0].getContext('2d');
const division_length = 100;
const text_color = canvas_el.css('color');
const refresh = window.setInterval(() => {
if (content_area.find('canvas').length === 0) {
window.clearInterval(refresh);
return;
}
canvas_el.trigger('render');
}, 1000 / TIMELINE_REFRESH_RATE);
const is_entry_selected = (entry, entry_i, all_entries) => {
const selected_request = canvas_el.closest('#debug-toolbar-expanded-content').data('requests_request_id');
let is_selected = false;
if (selected_request === this.initial_request.id && entry.type === 'navigation') {
is_selected = true;
} else {
const ajax_request = this.ajax_requests.find(r => r.id === selected_request);
if (ajax_request === undefined) {
return false;
}
const matches_by_url = [];
$.each(all_entries, (i, e) => {
if (e.name.endsWith(ajax_request.url)) {
matches_by_url.push({
i: i,
entry: e,
});
}
});
if (matches_by_url.length === 1 && matches_by_url[0].i === entry_i) {
is_selected = true;
} else {
// find the match that is closest to the section start
let closest_match = null;
matches_by_url.forEach((i, request) => {
if (closest_match === null) {
closest_match = request;
return;
}
const ajax_start = request.start - time_origin;
if (Math.abs(ajax_start - entry.start) < Math.abs(closest_match.start - entry.start)) {
closest_match = request;
}
});
is_selected = closest_match !== null && closest_match.i === entry_i;
}
}
return is_selected;
};
let hover_data = null;
canvas_el.on('render', () => {
if (canvas_el.hasClass('d-none')) {
return;
}
canvas_el.attr('width', canvas_el.parent().width());
const canvas_width = canvas_el.width();
const canvas_height = canvas_el.height();
// round end_ts to nearest 100 ms
const end_ts_rounded = Math.ceil(end_ts / 100) * 100;
const division_count = Math.min(Math.ceil(canvas_width / DIVIDER_WIDTH), Math.ceil(end_ts_rounded / division_length));
const dividers = [];
for (let i = 0; i < division_count; i++) {
dividers.push({
canvas_x: Math.round(canvas_width / division_count * i),
time: Math.ceil((end_ts_rounded / division_count) * i / 100) * 100
});
}
ctx.fillStyle = '#80808040';
ctx.fillRect(0, 0, canvas_width, canvas_height);
// draw division lines
$.each(dividers, (i, divider) => {
ctx.fillStyle = text_color;
ctx.strokeStyle = text_color;
ctx.font = ctx.font.replace(/\d+px/, '10px');
ctx.beginPath();
ctx.moveTo(divider.canvas_x, 0);
ctx.lineTo(divider.canvas_x, canvas_height);
ctx.stroke();
ctx.fillText(`${divider.time} ms`, divider.canvas_x + 2, 10);
});
// draw sections
$.each(sections, (i, row) => {
const row_y = (i * (ROW_HEIGHT + ROW_MARGIN)) + 12;
$.each(row, (i, entry) => {
const is_selected = is_entry_selected(entry, i, row);
const timings = entry.sections;
$.each(timings, (t, timing) => {
// if timing start and end are 0, skip
if (timing[0] === 0 && timing[1] === 0) {
return;
}
const color = this.TIMING_COLORS[t] || '#00aa00';
const width = Math.round((timing[1] - timing[0]) / end_ts * canvas_width);
const x = Math.round(timing[0] / end_ts * canvas_width);
entry.bounds[t] = {
x: x,
y: row_y,
width: width,
height: ROW_HEIGHT
};
ctx.fillStyle = color;
ctx.fillRect(x, row_y, width, ROW_HEIGHT);
if (is_selected) {
const stroke_style = ctx.strokeStyle;
ctx.strokeStyle = '#ffff00';
ctx.strokeRect(x, row_y, width, ROW_HEIGHT);
ctx.strokeRect(x - 1, row_y - 1, width + 2, ROW_HEIGHT + 2);
ctx.strokeStyle = stroke_style;
}
});
});
});
// draw tooltip
if (hover_data !== null) {
ctx.fillStyle = '#808080';
const section = hover_data.target.section;
const duration = section.sections[hover_data.target.timing][1] - section.sections[hover_data.target.timing][0];
let section_name = section.name;
if (section_name.length > 100) {
section_name = section_name.slice(0, 100) + '...';
}
const text = `${section_name} ${hover_data.target.timing} (${duration.toFixed(0)} ms)`;
ctx.font = ctx.font.replace(/\d+px/, '14px');
const text_width = ctx.measureText(text).width;
ctx.fillRect(section.bounds[hover_data.target.timing].x, section.bounds[hover_data.target.timing].y + ROW_HEIGHT, text_width + 4, 18);
ctx.fillStyle = text_color;
ctx.fillText(text, section.bounds[hover_data.target.timing].x + 2, section.bounds[hover_data.target.timing].y + ROW_HEIGHT + 14);
}
});
canvas_el.on('mousemove', (e) => {
// get canvas x and y
const canvas_x = e.offsetX;
const canvas_y = e.offsetY;
// find the section that the mouse is over
let hover_target = null;
$.each(sections, (i, row) => {
$.each(row, (i, entry) => {
$.each(entry.bounds, (t, bounds) => {
if (canvas_x >= bounds.x && canvas_x <= bounds.x + bounds.width && canvas_y >= bounds.y && canvas_y <= bounds.y + bounds.height) {
hover_target = {
section: entry,
timing: t
};
return false;
}
});
if (hover_target !== null) {
return false;
}
});
if (hover_target !== null) {
return false;
}
});
if (hover_target !== null) {
hover_data = {
target: hover_target,
};
canvas_el.css('cursor', 'pointer');
} else {
hover_data = null;
canvas_el.css('cursor', 'default');
}
});
canvas_el.on('mouseleave', () => {
hover_data = null;
canvas_el.css('cursor', 'default');
});
canvas_el.on('click', () => {
if (hover_data !== null) {
const section = hover_data.target.section;
let selected_request_id = null;
if (section.type === 'navigation') {
selected_request_id = this.initial_request.id;
} else {
const matches_by_url = [];
$.each(this.ajax_requests, (i, request) => {
if (section.name.endsWith(request.url)) {
matches_by_url.push(request);
}
});
if (matches_by_url.length === 1) {
selected_request_id = matches_by_url[0].id;
} else {
// find the match that is closest to the section start
let closest_match = null;
matches_by_url.forEach((i, request) => {
if (closest_match === null) {
closest_match = request;
return;
}
const ajax_start = request.start - time_origin;
if (Math.abs(ajax_start - section.start) < Math.abs(closest_match.start - section.start)) {
closest_match = request;
}
});
if (closest_match !== null) {
selected_request_id = closest_match.id;
}
}
}
if (selected_request_id !== null) {
const main_content = canvas_el.closest('#debug-toolbar-expanded-content');
main_content.data('requests_request_id', selected_request_id);
this.showWidget(main_content.data('requests_active_widget') || 'request_summary', false, main_content.find('.request-details-content-area'), {
request_id: main_content.data('requests_request_id'),
});
canvas_el.trigger('render');
}
}
});
}
};