diff --git a/app/admin/controller/xhprof/Run.php b/app/admin/controller/xhprof/Run.php index 583b63a..6a0db04 100644 --- a/app/admin/controller/xhprof/Run.php +++ b/app/admin/controller/xhprof/Run.php @@ -78,4 +78,162 @@ class Run extends AdminController return $this->fetch(); } + + /** + * 采样详情:页面/接口同体 + * + * 页面模式(浏览器/弹层 iframe,无 Accept json):渲染详情页壳,顶部摘要在服务端渲染, + * 函数表与 SQL 摘要由 detail.js 以接口模式取同一 URL 补齐。 + * 接口模式(Accept: application/json,框架 RequestBase::isAjax 判别): + * 返回函数级聚合分析 JSON(XHProf 边模型,XHGui 同款算法)。 + * + * @NodeAnotation(title="详情") + */ + public function detail() + { + $id = (int) input('id', 0); + + if ($this->request->isAjax()) { + return $this->detailAnalysis($id); + } + + // 页面模式:渲染页面壳(get_page_data=1 时 fetch 自动转 assign 数据 JSON) + $run = $this->model->find($id); + if (!$run) { + $this->error('采样记录不存在或已被清理'); + } + $runArr = $run->toArray(); + $summary = [ + 'wall_time_ms' => round((int) $runArr['wall_time'] / 1000, 1), + 'cpu_time_ms' => round((int) $runArr['cpu_time'] / 1000, 1), + 'memory_peak_mb' => round((int) $runArr['memory_peak'] / 1048576, 2), + 'request_time_text' => date('Y-m-d H:i:s', (int) $runArr['request_time']), + 'url_display' => htmlspecialchars($runArr['url'] ?: '/', ENT_QUOTES, 'UTF-8'), + 'node_display' => htmlspecialchars((string) $runArr['node_id'], ENT_QUOTES, 'UTF-8'), + ]; + $this->assign('run', $runArr); + $this->assign('summary', $summary); + + return $this->fetch(); + } + + /** + * 函数级聚合分析(接口模式). + * + * XHProf 边模型(XHGui 同款): + * 1. inclusive(func) = Σ 所有 `X==>func` 边的 wt/ct/mu(callee 侧累计); + * main() 无入边,其 inclusive 由裸键 profile['main()'] 播种; + * 2. exclusive(func) = inclusive(func) - Σ 所有 `func==>callee` 边的 wt; + * 递归边(foo==>foo)一次计入 inclusive、一次扣减,对 exclusive 净影响为零; + * 3. 按 exclusive wt 降序取 top 100。 + * + * 两遍 O(n) 遍历,不对 LONGTEXT 做 SQL 匹配,SQL 摘要为纯 PHP 过滤聚合结果。 + * + * @param int $id 采样记录 id + */ + protected function detailAnalysis(int $id) + { + $run = $this->model->find($id); + if (!$run) { + $this->error('采样记录不存在或已被清理'); + } + $detail = \app\admin\model\XhprofRunDetail::find($id); + if (!$detail) { + $this->error('采样明细数据缺失'); + } + + $raw = json_decode((string) $detail->profile_data, true); + $profile = (is_array($raw) && isset($raw['profile']) && is_array($raw['profile'])) ? $raw['profile'] : []; + + // 第一遍:裸键播种(main())+ 边累计 callee 侧 inclusive + $inclWt = []; + $inclCt = []; + $inclMu = []; + foreach ($profile as $key => $stat) { + $wt = (int) ($stat['wt'] ?? 0); + $ct = (int) ($stat['ct'] ?? 0); + $mu = (int) ($stat['mu'] ?? 0); + if (strpos($key, '==>') === false) { + $fn = $key; + $inclWt[$fn] = ($inclWt[$fn] ?? 0) + $wt; + $inclCt[$fn] = ($inclCt[$fn] ?? 0) + $ct; + $inclMu[$fn] = ($inclMu[$fn] ?? 0) + $mu; + continue; + } + [$caller, $callee] = explode('==>', $key, 2); + $inclWt[$callee] = ($inclWt[$callee] ?? 0) + $wt; + $inclCt[$callee] = ($inclCt[$callee] ?? 0) + $ct; + $inclMu[$callee] = ($inclMu[$callee] ?? 0) + $mu; + } + + // 第二遍:exclusive = inclusive - Σ 出边 wt(caller 只出现无入边记录时兜底 0) + $exclWt = $inclWt; + foreach ($profile as $key => $stat) { + if (strpos($key, '==>') === false) { + continue; + } + [$caller, $callee] = explode('==>', $key, 2); + if (!isset($exclWt[$caller])) { + $exclWt[$caller] = 0; + $inclCt[$caller] = $inclCt[$caller] ?? 0; + $inclMu[$caller] = $inclMu[$caller] ?? 0; + } + $exclWt[$caller] -= (int) ($stat['wt'] ?? 0); + } + + // 汇总函数行 + SQL 摘要(think\db 前缀,大小写不敏感;独占耗时合计避免重复计数) + $rows = []; + $sqlCt = 0; + $sqlWt = 0; + $sqlFnCount = 0; + foreach ($inclWt as $fn => $wt) { + $excl = max(0, $exclWt[$fn] ?? 0); + $rows[] = [ + 'fn' => $fn, + 'ct' => $inclCt[$fn] ?? 0, + 'excl_wt' => $excl, + 'incl_wt' => $wt, + 'mu' => $inclMu[$fn] ?? 0, + ]; + if (stripos($fn, 'think\\db') === 0) { + $sqlCt += $inclCt[$fn] ?? 0; + $sqlWt += $excl; + $sqlFnCount++; + } + } + // caller-only 出现在 exclWt 但不在 inclWt 的函数(独占已兜底 0),不进入函数表 + usort($rows, function ($a, $b) { + return $b['excl_wt'] <=> $a['excl_wt']; + }); + $functions = array_slice($rows, 0, 100); + + $wallTime = (int) $run->wall_time ?: ($inclWt['main()'] ?? 0); + $sqlPct = $wallTime > 0 ? round($sqlWt * 100 / $wallTime, 2) : 0.0; + + $summary = [ + 'id' => (int) $run->id, + 'method' => $run->method, + 'url' => $run->url, + 'simple_url' => $run->simple_url, + 'node_id' => $run->node_id, + 'wall_time' => $wallTime, + 'cpu_time' => (int) $run->cpu_time, + 'memory_peak' => (int) $run->memory_peak, + 'request_time' => (float) $run->request_time, + 'request_time_text' => date('Y-m-d H:i:s', (int) $run->request_time), + 'function_total' => count($rows), + ]; + $payload = [ + 'summary' => $summary, + 'sql_summary' => [ + 'fn_count' => $sqlFnCount, + 'ct' => $sqlCt, + 'wt' => $sqlWt, + 'wt_pct' => $sqlPct, + ], + 'functions' => $functions, + ]; + + $this->success('获取成功', $payload); + } } \ No newline at end of file diff --git a/app/admin/view/xhprof/run/detail.html b/app/admin/view/xhprof/run/detail.html new file mode 100644 index 0000000..c731d31 --- /dev/null +++ b/app/admin/view/xhprof/run/detail.html @@ -0,0 +1,74 @@ +
+
+ +
+
+
+
总耗时
+
+ {$summary.wall_time_ms}ms +
CPU {$summary.cpu_time_ms}ms
+
+
+
+
+
+
SQL 占比
+
+ --% +
think\db* 独占耗时 / 总耗时
+
+
+
+
+
+
内存峰值
+
+ {$summary.memory_peak_mb}MB +
函数总数 -
+
+
+
+
+
+
请求信息
+
+
+ {$run.method|default='-'} + {$summary.url_display} +
+
{$summary.request_time_text} · 节点 {$summary.node_display}
+
+
+
+
+ + +
+
SQL 耗时摘要(think\db 前缀函数独占耗时合计)
+
+
+
数据加载中...
+
+
+ + +
+
函数级分析(按独占耗时降序 Top 100)
+
+
+
+
+
+
+ diff --git a/app/admin/view/xhprof/run/detail.js b/app/admin/view/xhprof/run/detail.js new file mode 100644 index 0000000..fb2eaa8 --- /dev/null +++ b/app/admin/view/xhprof/run/detail.js @@ -0,0 +1,104 @@ +$(function () { + var $table = $('#funcTable'); + var runId = parseInt($table.data('id'), 10); + if (!runId || isNaN(runId)) { + $('#sql-bar-text').text('缺少采样记录 id'); + return; + } + + var escapeHtml = function (s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }; + // 微秒 → 可读耗时 + var fmtUs = function (us) { + us = parseInt(us, 10) || 0; + if (us >= 1000000) { + return (us / 1000000).toFixed(2) + 's'; + } + return (us / 1000).toFixed(2) + 'ms'; + }; + var fmtBytes = function (b) { + b = parseInt(b, 10) || 0; + if (b >= 1048576) { + return (b / 1048576).toFixed(2) + 'MB'; + } + if (b >= 1024) { + return (b / 1024).toFixed(2) + 'KB'; + } + return b + 'B'; + }; + + // 接口模式请求同一路由(Accept: application/json 触发页面/接口同体的接口分支) + $.ajax({ + url: 'xhprof.run/detail', + type: 'get', + data: {id: runId}, + headers: {Accept: 'application/json'}, + dataType: 'json', + success: function (res) { + if (!res || res.code !== 0 || !res.data) { + var msg = (res && res.msg) ? res.msg : '数据加载失败'; + $('#sql-bar-text').text(msg); + layer.msg(msg, {icon: 2}); + return; + } + var d = res.data; + var sql = d.sql_summary || {}; + var pct = parseFloat(sql.wt_pct) || 0; + + // 顶部 SQL 占比卡片 + SQL 摘要条 + $('#summary-sql-pct').text(pct.toFixed(2)); + $('#sql-bar').css('width', Math.min(100, pct) + '%'); + $('#sql-bar-text').text( + 'SQL 独占耗时 ' + fmtUs(sql.wt || 0) + + '(占总耗时 ' + pct.toFixed(2) + '%),命中函数 ' + (sql.fn_count || 0) + + ' 个,调用 ' + (sql.ct || 0) + ' 次' + ); + $('#summary-fn-total').text(d.summary && d.summary.function_total ? d.summary.function_total : '-'); + + // 函数表:本地数据渲染(服务端已按独占耗时降序取 Top 100) + layui.use(['table'], function () { + var table = layui.table; + table.render({ + elem: '#funcTable', + data: d.functions || [], + even: true, + page: false, + limit: 1000, + defaultToolbar: ['filter'], + cols: [[ + { + field: 'fn', title: '函数', minWidth: 380, templet: function (o) { + return '' + escapeHtml(o.fn) + ''; + } + }, + {field: 'ct', title: '调用次数', width: 100, sort: true}, + { + field: 'excl_wt', title: '独占耗时', width: 130, sort: true, templet: function (o) { + return fmtUs(o.excl_wt); + } + }, + { + field: 'incl_wt', title: '包含耗时', width: 130, sort: true, templet: function (o) { + return fmtUs(o.incl_wt); + } + }, + { + field: 'mu', title: '内存增量', width: 120, sort: true, templet: function (o) { + return fmtBytes(o.mu); + } + } + ]] + }); + }); + }, + error: function () { + $('#sql-bar-text').text('请求失败,请刷新重试'); + layer.msg('请求失败', {icon: 2}); + } + }); +});