Bicycle/System/Classes/ProfilerToolbar.php
Egor Isaev 69ce89a695 dev
2026-08-11 17:05:39 +03:00

351 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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;
}
}