> Произвольные данные, добавленные через 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 .= '
' .
'' . number_format($entry['time_ms'], 2) . ' ms ' .
'' . htmlspecialchars($entry['sql'], ENT_QUOTES, 'UTF-8') . ' ' .
'' . htmlspecialchars(json_encode($entry['params'], JSON_UNESCAPED_UNICODE), ENT_QUOTES, 'UTF-8') . ' ' .
'' . self::explain($entry['sql'], $entry['params']) . ' ' .
' ';
}
return 'Время SQL Параметры EXPLAIN ' .
$rows . '
';
}
/**
* Прогоняет 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 = '' . implode('', array_map(
static fn (string $col) => '' . htmlspecialchars($col, ENT_QUOTES, 'UTF-8') . ' ', array_keys($rows[0])
)) . ' ';
$body = '';
foreach ($rows as $row) {
$body .= '' . implode('', array_map(
static fn ($val) => '' . htmlspecialchars((string) ($val ?? 'NULL'), ENT_QUOTES, 'UTF-8') . ' ', $row
)) . ' ';
}
return '' . $head . ' ' . $body . '
';
}
/**
* @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 .= '' . $name . ' (' . count($data) . ') ' . 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 .= '' .
'' . ($i + 1) . ' ' .
'' . htmlspecialchars($file, ENT_QUOTES, 'UTF-8') . ' ' .
'' . number_format($file_size / 1024, 1) . ' KB ' .
' ';
}
return 'Всего: ' . count($files) . ' (' . number_format($size / 1024 / 1024, 2) . ' MB)
' .
'';
}
/**
* @return string HTML вкладки Route — текущий контроллер/экшен/параметры
*/
private static function routeTab(): string
{
$request = Request::$current;
if ($request === null) {
return 'Request::$current не установлен
';
}
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 .= '' . htmlspecialchars($tab, ENT_QUOTES, 'UTF-8') . ' (' . count($items) . ') ';
foreach ($items as $i => $item) {
$dump = is_string($item) ? $item : var_export($item, true);
$html .= '#' . ($i + 1) . ' ' . htmlspecialchars($dump, ENT_QUOTES, 'UTF-8') . ' ';
}
}
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 $data
* @return string HTML-таблица ключ/значение
*/
private static function keyValueTable(array $data): string
{
if ($data === []) {
return 'пусто
';
}
$rows = '';
foreach ($data as $key => $value) {
$value_str = is_array($value) ? json_encode($value, JSON_UNESCAPED_UNICODE) : (string) $value;
$rows .= '' . htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8') . ' ' .
'' . htmlspecialchars($value_str, ENT_QUOTES, 'UTF-8') . ' ';
}
return '';
}
/**
* @param float $time_ms Время выполнения запроса, мс
* @param int $memory Пиковое потребление памяти, байт
* @param int $query_count Число SQL-запросов
* @param float $query_time Суммарное время SQL-запросов, мс
* @param array $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 .= '' .
htmlspecialchars($tab['label'], ENT_QUOTES, 'UTF-8') . ' ';
$panels .= '' . $tab['html'] . '
';
$first = false;
}
return <<
Profiler
время: {$time} ms
память: {$memory_mb} MB
SQL: {$query_count} ({$query_ms} ms)
HTML;
}
}