mirror of
https://gitee.com/ulthon/ulthon_admin.git
synced 2026-09-04 23:13:26 +08:00
feat(admin): XHProf 运行详情页函数级分析
This commit is contained in:
@@ -78,4 +78,162 @@ class Run extends AdminController
|
|||||||
|
|
||||||
return $this->fetch();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
74
app/admin/view/xhprof/run/detail.html
Normal file
74
app/admin/view/xhprof/run/detail.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<div class="layuimini-container">
|
||||||
|
<div class="layuimini-main" style="padding: 10px 15px;">
|
||||||
|
<!-- 顶部摘要卡片(服务端渲染) -->
|
||||||
|
<div class="layui-row layui-col-space15">
|
||||||
|
<div class="layui-col-md3 layui-col-sm6">
|
||||||
|
<div class="layui-card xhprof-summary-card">
|
||||||
|
<div class="layui-card-header">总耗时</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<span class="xhprof-num">{$summary.wall_time_ms}</span><span class="xhprof-unit">ms</span>
|
||||||
|
<div class="xhprof-sub">CPU {$summary.cpu_time_ms}ms</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-col-md3 layui-col-sm6">
|
||||||
|
<div class="layui-card xhprof-summary-card">
|
||||||
|
<div class="layui-card-header">SQL 占比</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<span class="xhprof-num" id="summary-sql-pct">--</span><span class="xhprof-unit">%</span>
|
||||||
|
<div class="xhprof-sub">think\db* 独占耗时 / 总耗时</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-col-md3 layui-col-sm6">
|
||||||
|
<div class="layui-card xhprof-summary-card">
|
||||||
|
<div class="layui-card-header">内存峰值</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<span class="xhprof-num">{$summary.memory_peak_mb}</span><span class="xhprof-unit">MB</span>
|
||||||
|
<div class="xhprof-sub">函数总数 <span id="summary-fn-total">-</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layui-col-md3 layui-col-sm6">
|
||||||
|
<div class="layui-card xhprof-summary-card">
|
||||||
|
<div class="layui-card-header">请求信息</div>
|
||||||
|
<div class="layui-card-body xhprof-request-card">
|
||||||
|
<div>
|
||||||
|
<span class="layui-badge layui-bg-blue">{$run.method|default='-'}</span>
|
||||||
|
<span class="xhprof-url" title="{$summary.url_display}">{$summary.url_display}</span>
|
||||||
|
</div>
|
||||||
|
<div class="xhprof-sub">{$summary.request_time_text} · 节点 {$summary.node_display}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SQL 摘要条(JS 以接口模式取数填充) -->
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">SQL 耗时摘要(think\db 前缀函数独占耗时合计)</div>
|
||||||
|
<div class="layui-card-body">
|
||||||
|
<div class="xhprof-bar-track"><div class="xhprof-bar-fill" id="sql-bar" style="width: 0;"></div></div>
|
||||||
|
<div class="xhprof-bar-text" id="sql-bar-text">数据加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 函数级分析表(JS 以接口模式取数后本地渲染) -->
|
||||||
|
<div class="layui-card">
|
||||||
|
<div class="layui-card-header">函数级分析(按独占耗时降序 Top 100)</div>
|
||||||
|
<div class="layui-card-body" style="padding: 5px 10px;">
|
||||||
|
<table id="funcTable" class="layui-table" data-id="{$run.id}" lay-filter="funcTable"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.xhprof-summary-card .layui-card-body { padding: 12px 15px; }
|
||||||
|
.xhprof-num { font-size: 26px; font-weight: 600; color: #16baaa; }
|
||||||
|
.xhprof-num.is-warn { color: #ff5722; }
|
||||||
|
.xhprof-unit { margin-left: 4px; color: #999; }
|
||||||
|
.xhprof-sub { margin-top: 4px; color: #999; font-size: 12px; }
|
||||||
|
.xhprof-request-card .xhprof-url { margin-left: 6px; font-weight: 600; word-break: break-all; }
|
||||||
|
.xhprof-bar-track { height: 18px; background: #f2f2f2; border-radius: 9px; overflow: hidden; }
|
||||||
|
.xhprof-bar-fill { height: 100%; background: linear-gradient(90deg, #16b777, #16baaa); border-radius: 9px; transition: width .4s ease; min-width: 0; }
|
||||||
|
.xhprof-bar-text { margin-top: 6px; color: #666; font-size: 12px; }
|
||||||
|
</style>
|
||||||
104
app/admin/view/xhprof/run/detail.js
Normal file
104
app/admin/view/xhprof/run/detail.js
Normal file
@@ -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, '>')
|
||||||
|
.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 '<span title="' + escapeHtml(o.fn) + '">' + escapeHtml(o.fn) + '</span>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{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});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user