> Произвольные данные, добавленные через 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() // через тот же PdoDriver) — иначе шапка "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 '' . $rows . '
ВремяSQLПараметрыEXPLAIN
'; } /** * Прогоняет 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)

' . '' . $rows . '
№ФайлРазмер
'; } /** * @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 '' . $rows . '
'; } /** * @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 .= ''; $panels .= '
' . $tab['html'] . '
'; $first = false; } return <<
Profiler время: {$time} ms память: {$memory_mb} MB SQL: {$query_count} ({$query_ms} ms)
HTML; } }