351 lines
16 KiB
PHP
351 lines
16 KiB
PHP
<?php
|
||
/**
|
||
* @package Bicycle
|
||
* @author Egor Isaev
|
||
* @description ProfilerToolbar.php
|
||
* @copyright (c) 07/08/2026
|
||
*/
|
||
|
||
namespace System\Classes;
|
||
|
||
use Services\Auth;
|
||
use Services\Database;
|
||
use Services\DataBase\Classes\Profiler;
|
||
|
||
/**
|
||
* Debug-панель внизу страницы (аналог ProfilerToolbar для Kohana,
|
||
* github.com/Alert/profilertoolbar): время/память, SQL-запросы
|
||
* (Services\DataBase\Classes\Profiler), GET/POST/COOKIE/SESSION/SERVER,
|
||
* подключённые файлы, текущий route, произвольные данные через addData().
|
||
* Видна только role === 'admin' (Services\Auth) — render() отдаёт пустую
|
||
* строку всем остальным. FireBug-вывод и подсветка исходников из оригинала
|
||
* не портированы — устаревшие технологии, не нужны для этого проекта.
|
||
*/
|
||
class ProfilerToolbar
|
||
{
|
||
/** @var array<string,array<int,mixed>> Произвольные данные, добавленные через addData() */
|
||
private static array $_custom = [];
|
||
|
||
/**
|
||
* Добавляет произвольные данные во вкладку Custom панели — вызывать из любого места кода
|
||
* для отладки (аналог Kohana_ProfilerToolbar::addData()).
|
||
*
|
||
* @param mixed $data Что угодно — выводится через var_export()
|
||
* @param string $tab Имя подвкладки внутри Custom (группировка, если данных несколько)
|
||
* @return void
|
||
*/
|
||
public static function addData(mixed $data, string $tab = 'custom'): void
|
||
{
|
||
self::$_custom[$tab][] = $data;
|
||
}
|
||
|
||
/**
|
||
* Рендерит панель, если текущий пользователь — admin, иначе — пустую строку.
|
||
*
|
||
* @return string
|
||
*/
|
||
public static function render(): string
|
||
{
|
||
$user = Auth::instance()->getUser();
|
||
|
||
if (($user['role'] ?? null) !== 'admin') {
|
||
return '';
|
||
}
|
||
|
||
$time_ms = (microtime(true) - ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) * 1000;
|
||
$memory = memory_get_peak_usage(true);
|
||
// Захватываем ДО sqlTab(): EXPLAIN внутри неё сам логируется в Profiler (обычный query()
|
||
// через тот же PdoConnection) — иначе шапка "SQL: N (X ms)" включила бы наши же debug-запросы.
|
||
$query_count = Profiler::count();
|
||
$query_time = Profiler::totalTime();
|
||
|
||
$tabs = [
|
||
'sql' => ['label' => 'SQL (' . $query_count . ')', 'html' => self::sqlTab()],
|
||
'vars' => ['label' => 'Vars', 'html' => self::varsTab()],
|
||
'files' => ['label' => 'Files (' . count(get_included_files()) . ')', 'html' => self::filesTab()],
|
||
'route' => ['label' => 'Route', 'html' => self::routeTab()],
|
||
];
|
||
|
||
if (self::$_custom !== []) {
|
||
$tabs['custom'] = ['label' => 'Custom', 'html' => self::customTab()];
|
||
}
|
||
|
||
return self::html($time_ms, $memory, $query_count, $query_time, $tabs);
|
||
}
|
||
|
||
/**
|
||
* @return string HTML вкладки SQL — список запросов с временем и параметрами
|
||
*/
|
||
private static function sqlTab(): string
|
||
{
|
||
$rows = '';
|
||
|
||
foreach (Profiler::entries() as $entry) {
|
||
$rows .= '<tr>' .
|
||
'<td>' . number_format($entry['time_ms'], 2) . ' ms</td>' .
|
||
'<td><code>' . htmlspecialchars($entry['sql'], ENT_QUOTES, 'UTF-8') . '</code></td>' .
|
||
'<td><code>' . htmlspecialchars(json_encode($entry['params'], JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8') . '</code></td>' .
|
||
'<td>' . self::explain($entry['sql'], $entry['params']) . '</td>' .
|
||
'</tr>';
|
||
}
|
||
|
||
return '<table class="ptb-table"><thead><tr><th>Время</th><th>SQL</th><th>Параметры</th><th>EXPLAIN</th></tr></thead><tbody>' .
|
||
$rows . '</tbody></table>';
|
||
}
|
||
|
||
/**
|
||
* Прогоняет EXPLAIN для SELECT-запроса тем же подключением (Database::instance()) —
|
||
* только для отображения в панели, не влияет на сам запрос. Не для не-SELECT
|
||
* и не при ошибке (например запрос уже недействителен вне транзакции) — тогда просто '—'.
|
||
*
|
||
* @param string $sql Исходный SQL-запрос
|
||
* @param array $params Параметры запроса (те же, что были у оригинального запроса)
|
||
* @return string HTML-таблица EXPLAIN или '—'
|
||
*/
|
||
private static function explain(string $sql, array $params): string
|
||
{
|
||
if (stripos(ltrim($sql), 'select') !== 0) {
|
||
return '—';
|
||
}
|
||
|
||
try {
|
||
// skip_profiling — иначе сам EXPLAIN попадёт в Profiler и засорит счётчик SQL панели.
|
||
$stmt = Database::instance()->prepare('EXPLAIN ' . $sql);
|
||
$stmt->skip_profiling = true;
|
||
$stmt->execute($params);
|
||
$rows = $stmt->fetchAll();
|
||
} catch (\Throwable) {
|
||
return '—';
|
||
}
|
||
|
||
if ($rows === []) {
|
||
return '—';
|
||
}
|
||
|
||
$head = '<tr>' . implode('', array_map(
|
||
static fn (string $col) => '<th>' . htmlspecialchars($col, ENT_QUOTES, 'UTF-8') . '</th>', array_keys($rows[0])
|
||
)) . '</tr>';
|
||
|
||
$body = '';
|
||
foreach ($rows as $row) {
|
||
$body .= '<tr>' . implode('', array_map(
|
||
static fn ($val) => '<td>' . htmlspecialchars((string) ($val ?? 'NULL'), ENT_QUOTES, 'UTF-8') . '</td>', $row
|
||
)) . '</tr>';
|
||
}
|
||
|
||
return '<table class="ptb-table ptb-explain"><thead>' . $head . '</thead><tbody>' . $body . '</tbody></table>';
|
||
}
|
||
|
||
/**
|
||
* @return string HTML вкладки Vars — GET/POST/COOKIE/SESSION/SERVER (чувствительные ключи маскируются)
|
||
*/
|
||
private static function varsTab(): string
|
||
{
|
||
$request = Request::$current;
|
||
$groups = [
|
||
'GET' => $request?->query() ?? $_GET,
|
||
'POST' => $request?->post() ?? $_POST,
|
||
'COOKIE' => $_COOKIE,
|
||
'SESSION' => Session::instance()->getData(),
|
||
'SERVER' => $_SERVER,
|
||
];
|
||
|
||
$html = '';
|
||
|
||
foreach ($groups as $name => $data) {
|
||
$data = self::mask((array) $data);
|
||
$html .= '<h4 class="ptb-h4">' . $name . ' (' . count($data) . ')</h4>' . self::keyValueTable($data);
|
||
}
|
||
|
||
return $html;
|
||
}
|
||
|
||
/**
|
||
* @return string HTML вкладки Files — все подключённые к запросу файлы (get_included_files())
|
||
*/
|
||
private static function filesTab(): string
|
||
{
|
||
$files = get_included_files();
|
||
$rows = '';
|
||
$size = 0;
|
||
|
||
foreach ($files as $i => $file) {
|
||
$file_size = is_file($file) ? filesize($file) : 0;
|
||
$size += $file_size;
|
||
|
||
$rows .= '<tr>' .
|
||
'<td>' . ($i + 1) . '</td>' .
|
||
'<td><code>' . htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . '</code></td>' .
|
||
'<td>' . number_format($file_size / 1024, 1) . ' KB</td>' .
|
||
'</tr>';
|
||
}
|
||
|
||
return '<p>Всего: ' . count($files) . ' (' . number_format($size / 1024 / 1024, 2) . ' MB)</p>' .
|
||
'<table class="ptb-table"><thead><tr><th>№</th><th>Файл</th><th>Размер</th></tr></thead><tbody>' .
|
||
$rows . '</tbody></table>';
|
||
}
|
||
|
||
/**
|
||
* @return string HTML вкладки Route — текущий контроллер/экшен/параметры
|
||
*/
|
||
private static function routeTab(): string
|
||
{
|
||
$request = Request::$current;
|
||
|
||
if ($request === null) {
|
||
return '<p>Request::$current не установлен</p>';
|
||
}
|
||
|
||
return self::keyValueTable([
|
||
'uri' => $request->uri(),
|
||
'method' => $request->method(),
|
||
'controller' => $request->controller(),
|
||
'action' => $request->action(),
|
||
'params' => json_encode($request->params(), JSON_UNESCAPED_UNICODE),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @return string HTML вкладки Custom — данные, добавленные через addData()
|
||
*/
|
||
private static function customTab(): string
|
||
{
|
||
$html = '';
|
||
|
||
foreach (self::$_custom as $tab => $items) {
|
||
$html .= '<h4 class="ptb-h4">' . htmlspecialchars($tab, ENT_QUOTES, 'UTF-8') . ' (' . count($items) . ')</h4>';
|
||
|
||
foreach ($items as $i => $item) {
|
||
$dump = is_string($item) ? $item : var_export($item, true);
|
||
$html .= '<pre class="ptb-pre">#' . ($i + 1) . ' ' . htmlspecialchars($dump, ENT_QUOTES, 'UTF-8') . '</pre>';
|
||
}
|
||
}
|
||
|
||
return $html;
|
||
}
|
||
|
||
/**
|
||
* Рекурсивно маскирует значения чувствительных ключей (см. Log::$mask_keys).
|
||
*
|
||
* @param array $data
|
||
* @return array
|
||
*/
|
||
private static function mask(array $data): array
|
||
{
|
||
$mask = array_map('strtolower', Log::$mask_keys);
|
||
$result = [];
|
||
|
||
foreach ($data as $key => $value) {
|
||
if (in_array(strtolower((string) $key), $mask, true)) {
|
||
$result[$key] = '***';
|
||
} elseif (is_array($value)) {
|
||
$result[$key] = self::mask($value);
|
||
} else {
|
||
$result[$key] = $value;
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $data
|
||
* @return string HTML-таблица ключ/значение
|
||
*/
|
||
private static function keyValueTable(array $data): string
|
||
{
|
||
if ($data === []) {
|
||
return '<p class="ptb-empty">пусто</p>';
|
||
}
|
||
|
||
$rows = '';
|
||
|
||
foreach ($data as $key => $value) {
|
||
$value_str = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string) $value;
|
||
$rows .= '<tr><td><code>' . htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8') . '</code></td>' .
|
||
'<td><code>' . htmlspecialchars($value_str, ENT_QUOTES, 'UTF-8') . '</code></td></tr>';
|
||
}
|
||
|
||
return '<table class="ptb-table"><tbody>' . $rows . '</tbody></table>';
|
||
}
|
||
|
||
/**
|
||
* @param float $time_ms Время выполнения запроса, мс
|
||
* @param int $memory Пиковое потребление памяти, байт
|
||
* @param int $query_count Число SQL-запросов
|
||
* @param float $query_time Суммарное время SQL-запросов, мс
|
||
* @param array<string,array{label:string,html:string}> $tabs Вкладки панели
|
||
* @return string
|
||
*/
|
||
private static function html(float $time_ms, int $memory, int $query_count, float $query_time, array $tabs): string
|
||
{
|
||
$memory_mb = number_format($memory / 1024 / 1024, 2);
|
||
$time = number_format($time_ms, 1);
|
||
$query_ms = number_format($query_time, 1);
|
||
|
||
$buttons = '';
|
||
$panels = '';
|
||
$first = true;
|
||
|
||
foreach ($tabs as $name => $tab) {
|
||
$display = $first ? 'block' : 'none';
|
||
$active = $first ? ' ptb-tab-btn-active' : '';
|
||
|
||
$buttons .= '<button type="button" class="ptb-tab-btn' . $active . '" onclick="ptbTab(this,\'' . $name . '\')">' .
|
||
htmlspecialchars($tab['label'], ENT_QUOTES, 'UTF-8') . '</button>';
|
||
$panels .= '<div id="ptb-tab-' . $name . '" class="ptb-tab" style="display:' . $display . '">' . $tab['html'] . '</div>';
|
||
|
||
$first = false;
|
||
}
|
||
|
||
return <<<HTML
|
||
<div id="profiler-toolbar" style="position:fixed;left:0;right:0;bottom:0;z-index:99999;
|
||
font:12px/1.4 monospace;background:#1b1b1b;color:#ddd;border-top:2px solid #444;">
|
||
<style>
|
||
/* Общий сброс — панель должна выглядеть одинаково независимо от того, что уже
|
||
загружено на странице-хосте (Bootstrap на обычных страницах красит code в розовый
|
||
и даёт h4 крупный шрифт; на error.html Bootstrap не подключён вовсе — там того
|
||
же текста, наоборот, не хватало цвета из-за quirks mode без <!DOCTYPE html>).
|
||
Задаём всё явно на каждом потомке, а не полагаемся на наследование. */
|
||
#profiler-toolbar, #profiler-toolbar * {
|
||
font-family:ui-monospace,"SF Mono","Cascadia Code",monospace;
|
||
font-size:12px;font-weight:400;line-height:1.4;color:#ddd;
|
||
margin:0;padding:0;text-align:left;background:none;border:0;box-sizing:border-box;
|
||
}
|
||
#profiler-toolbar code {color:#ff79c6;}
|
||
#profiler-toolbar .ptb-table {width:100%;border-collapse:collapse;}
|
||
#profiler-toolbar .ptb-table th, #profiler-toolbar .ptb-table td {padding:3px 10px;text-align:left;border-bottom:1px solid #333;vertical-align:top;}
|
||
#profiler-toolbar .ptb-table th {color:#999;}
|
||
#profiler-toolbar .ptb-explain {font-size:11px;}
|
||
#profiler-toolbar .ptb-explain td, #profiler-toolbar .ptb-explain th {padding:2px 6px;}
|
||
#profiler-toolbar .ptb-h4 {margin:8px 10px 2px;color:#8bc34a;font-weight:600;}
|
||
#profiler-toolbar .ptb-pre {margin:4px 10px;white-space:pre-wrap;word-break:break-all;}
|
||
#profiler-toolbar .ptb-empty {margin:4px 10px;color:#777;}
|
||
#profiler-toolbar .ptb-tab-btn {padding:6px 12px;cursor:pointer;border-bottom:2px solid transparent;}
|
||
#profiler-toolbar .ptb-tab-btn-active {border-bottom-color:#8bc34a;color:#8bc34a;}
|
||
</style>
|
||
<div style="display:flex;gap:16px;align-items:center;padding:4px 12px;cursor:pointer;"
|
||
onclick="var b=document.getElementById('profiler-toolbar-body');b.style.display=b.style.display==='none'?'block':'none';">
|
||
<strong style="color:#8bc34a;">Profiler</strong>
|
||
<span>время: {$time} ms</span>
|
||
<span>память: {$memory_mb} MB</span>
|
||
<span>SQL: {$query_count} ({$query_ms} ms)</span>
|
||
</div>
|
||
<div id="profiler-toolbar-body" style="display:none;border-top:1px solid #444;">
|
||
<div style="border-bottom:1px solid #333;">{$buttons}</div>
|
||
<div style="max-height:300px;overflow:auto;">{$panels}</div>
|
||
</div>
|
||
<script>
|
||
function ptbTab(btn, name) {
|
||
var root = document.getElementById('profiler-toolbar');
|
||
root.querySelectorAll('.ptb-tab').forEach(function (el) { el.style.display = 'none'; });
|
||
root.querySelectorAll('.ptb-tab-btn').forEach(function (el) { el.classList.remove('ptb-tab-btn-active'); });
|
||
document.getElementById('ptb-tab-' + name).style.display = 'block';
|
||
btn.classList.add('ptb-tab-btn-active');
|
||
}
|
||
</script>
|
||
</div>
|
||
HTML;
|
||
}
|
||
}
|