feat(admin): XHProf 采样列表页

- curd -t ul_xhprof_run 生成三件套(控制器 xhprof.run 子目录形态,model docblock 随生成刷新)
- 只读化:toolbar 清空 + 行内仅详情按钮(open 弹层 xhprof.run/detail?id=N,auth=detail),
  add/edit/delete 等 action 保留靠权限节点不分配拦截(T8 对齐)
- 列渲染:wall_time/cpu_time μs→ms(>1000ms 红色)、memory_peak 字节→MB、
  url 截断 title 全显、method 标签化、request_time 浮点秒→日期时间
- 四组搜索:url like / request_time range / wall_time 下限(前端 ms,后端换算 μs,
  withGet 全量合并写回)/ node_id 下拉(DISTINCT + data_brage 通道)
- 生成前 scheme:sync 修复 request_time 的 null/comment 漂移(T3 手工 ALTER 遗留)
This commit is contained in:
augushong
2026-08-15 06:48:45 +08:00
parent a43a9a48b9
commit e57b186126
11 changed files with 489 additions and 10 deletions

View File

@@ -0,0 +1,81 @@
<?php
namespace app\admin\controller\xhprof;
use app\common\controller\AdminController;
use app\admin\service\annotation\ControllerAnnotation;
use app\admin\service\annotation\NodeAnotation;
use think\App;
/**
* XHProf 性能采样记录(只读列表)
* 数据由定时任务从 jsonl 导入,后台仅查看与函数级分析,不支持手动增删改。
*
* @ControllerAnnotation(title="XHProf 性能采样记录")
*/
class Run extends AdminController
{
use \app\admin\traits\Curd;
protected $sort = [
'request_time' => 'desc',
];
public function __construct(App $app)
{
parent::__construct($app);
$this->model = new \app\admin\model\XhprofRun();
}
/**
* @NodeAnotation(title="列表")
*/
public function index()
{
if ($this->request->isAjax()) {
if (input('selectFields')) {
return $this->selectList();
}
// wall_time 搜索下限:前端输入毫秒,换算为微秒后再构建查询条件
// 注意Request::withGet 是整体替换,必须先取全量 GET 再合并写回
$filter = (string) $this->request->get('filter', '');
if ($filter !== '') {
$filterArr = json_decode($filter, true) ?: [];
if (isset($filterArr['wall_time']) && $filterArr['wall_time'] !== '') {
$filterArr['wall_time'] = (string) ((float) $filterArr['wall_time'] * 1000);
$this->request->withGet(array_merge($this->request->get(), [
'filter' => json_encode($filterArr, JSON_UNESCAPED_UNICODE),
]));
}
}
list($page, $limit, $where, $excludes, $request_options, $group) = $this->buildTableParames();
$count = $this->model
->where($where)
->group($group)
->count();
$list = $this->model
->where($where)
->page($page, $limit)
->order($this->sort)
->group($group)
->select();
$data = [
'code' => 0,
'msg' => '',
'count' => $count,
'data' => $list,
];
return json($data);
}
// 节点下拉选项(多节点环境区分数据来源)
$nodeIds = $this->model->distinct(true)->column('node_id');
$nodeSelect = array_combine($nodeIds, $nodeIds) ?: [];
$this->assign('select_list_node_id', $nodeSelect, true);
return $this->fetch();
}
}

View File

@@ -5,16 +5,16 @@ namespace app\admin\model;
use app\common\model\TimeModel; use app\common\model\TimeModel;
/** /**
* @property int $id 主键ID * @property int $id
* @property string $node_id 节点ID区分不同采集节点 * @property string $node_id 节点ID区分不同采集节点
* @property string $url 请求完整 URL超长截断 * @property string $url 请求完整 URL超长截断
* @property string $simple_url 规范化 URL去 query、数字段替换聚合用 * @property string $simple_url 规范化 URL去 query、数字段替换聚合用
* @property string $method HTTP 方法 * @property string $method HTTP 方法
* @property int $wall_time 总耗时微秒main() 的 wt * @property int $wall_time 总耗时微秒main 的 wt
* @property int $cpu_time CPU 耗时微秒main() 的 cpu * @property int $cpu_time CPU 耗时微秒main 的 cpu
* @property int $memory_peak 内存峰值字节main() 的 pmu * @property int $memory_peak 内存峰值字节main 的 pmu
* @property string $request_time 请求开始时间REQUEST_TIME_FLOAT 浮点秒) * @property float|double $request_time 请求开始时间REQUEST_TIME_FLOAT 浮点秒)
* @property int $create_time 入库时间戳 * @property int $create_time 入库时间戳
*/ */
class XhprofRun extends TimeModel class XhprofRun extends TimeModel
{ {
@@ -23,4 +23,8 @@ class XhprofRun extends TimeModel
protected $deleteTime = false; protected $deleteTime = false;
} }

View File

@@ -0,0 +1,12 @@
var init = {
tableElem: '#currentTable',
tableRenderId: 'currentTableRenderId',
indexUrl: 'xhprof.run/index',
addUrl: '',
editUrl: '',
readUrl: '',
deleteUrl: '',
exportUrl: '',
modifyUrl: '',
detailUrl: 'xhprof.run/detail',
};

View File

@@ -0,0 +1,62 @@
<div class="layuimini-container">
<form id="app-form" class="layui-form layuimini-form">
<div class="layui-form-item">
<label class="layui-form-label">节点ID区分不同采集节点</label>
<div class="layui-input-block">
<input type="text" name="node_id" class="layui-input" lay-verify="required" placeholder="请输入节点ID区分不同采集节点" value="{$Request.param.node_id|default='default'}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">请求完整 URL超长截断</label>
<div class="layui-input-block">
<input type="text" name="url" class="layui-input" lay-verify="required" placeholder="请输入请求完整 URL超长截断" value="{$Request.param.url|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">规范化 URL query、数字段替换聚合用</label>
<div class="layui-input-block">
<input type="text" name="simple_url" class="layui-input" lay-verify="required" placeholder="请输入规范化 URL去 query、数字段替换聚合用" value="{$Request.param.simple_url|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">HTTP 方法</label>
<div class="layui-input-block">
<input type="text" name="method" class="layui-input" lay-verify="required" placeholder="请输入HTTP 方法" value="{$Request.param.method|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">总耗时微秒main wt</label>
<div class="layui-input-block">
<input type="text" name="wall_time" class="layui-input" placeholder="请输入总耗时微秒main 的 wt" value="{$Request.param.wall_time|default='0'}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">CPU 耗时微秒main cpu</label>
<div class="layui-input-block">
<input type="text" name="cpu_time" class="layui-input" placeholder="请输入CPU 耗时微秒main 的 cpu" value="{$Request.param.cpu_time|default='0'}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">内存峰值字节main pmu</label>
<div class="layui-input-block">
<input type="text" name="memory_peak" class="layui-input" placeholder="请输入内存峰值字节main 的 pmu" value="{$Request.param.memory_peak|default='0'}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">请求开始时间REQUEST_TIME_FLOAT 浮点秒)</label>
<div class="layui-input-block">
<input type="text" name="request_time" class="layui-input" placeholder="请输入请求开始时间REQUEST_TIME_FLOAT 浮点秒)" value="{$Request.param.request_time|default='0.000'}">
</div>
</div>
<div class="hr-line"></div>
<div class="layui-form-item text-center">
{notempty name='$Request.param.backTagId'}
<div class="layui-btn layui-btn-sm page-back-button" layuimini-content-href="{$Request.param.backTagId}" data-back="1">返回</div>
{/notempty}
<button type="submit" class="layui-btn layui-btn-normal layui-btn-sm" lay-submit>确认</button>
<button type="reset" class="layui-btn layui-btn-primary layui-btn-sm">重置</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,3 @@
$(function(){
ua.listen();
})

View File

@@ -0,0 +1,62 @@
<div class="layuimini-container">
<form id="app-form" class="layui-form layuimini-form">
<div class="layui-form-item">
<label class="layui-form-label">节点ID区分不同采集节点</label>
<div class="layui-input-block">
<input type="text" name="node_id" class="layui-input" lay-verify="required" placeholder="请输入节点ID区分不同采集节点" value="{$row.node_id|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">请求完整 URL超长截断</label>
<div class="layui-input-block">
<input type="text" name="url" class="layui-input" lay-verify="required" placeholder="请输入请求完整 URL超长截断" value="{$row.url|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">规范化 URL query、数字段替换聚合用</label>
<div class="layui-input-block">
<input type="text" name="simple_url" class="layui-input" lay-verify="required" placeholder="请输入规范化 URL去 query、数字段替换聚合用" value="{$row.simple_url|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">HTTP 方法</label>
<div class="layui-input-block">
<input type="text" name="method" class="layui-input" lay-verify="required" placeholder="请输入HTTP 方法" value="{$row.method|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">总耗时微秒main wt</label>
<div class="layui-input-block">
<input type="text" name="wall_time" class="layui-input" placeholder="请输入总耗时微秒main 的 wt" value="{$row.wall_time|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">CPU 耗时微秒main cpu</label>
<div class="layui-input-block">
<input type="text" name="cpu_time" class="layui-input" placeholder="请输入CPU 耗时微秒main 的 cpu" value="{$row.cpu_time|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">内存峰值字节main pmu</label>
<div class="layui-input-block">
<input type="text" name="memory_peak" class="layui-input" placeholder="请输入内存峰值字节main 的 pmu" value="{$row.memory_peak|default=''}">
</div>
</div>
<div class="layui-form-item">
<label class="layui-form-label">请求开始时间REQUEST_TIME_FLOAT 浮点秒)</label>
<div class="layui-input-block">
<input type="text" name="request_time" class="layui-input" placeholder="请输入请求开始时间REQUEST_TIME_FLOAT 浮点秒)" value="{$row.request_time|default=''}">
</div>
</div>
<div class="hr-line"></div>
<div class="layui-form-item text-center">
{notempty name='$Request.param.backTagId'}
<div class="layui-btn layui-btn-sm page-back-button" layuimini-content-href="{$Request.param.backTagId}" data-back="1">返回</div>
{/notempty}
<button type="submit" class="layui-btn layui-btn-normal layui-btn-sm" lay-submit>确认</button>
<button type="reset" class="layui-btn layui-btn-primary layui-btn-sm">重置</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,3 @@
$(function(){
ua.listen();
})

View File

@@ -0,0 +1,9 @@
<div class="layuimini-container">
<div class="layuimini-main">
<table id="currentTable" class="layui-table layui-hide"
data-auth-index="{:auth('xhprof.run/index')}"
data-auth-detail="{:auth('xhprof.run/detail')}"
lay-filter="currentTable">
</table>
</div>
</div>

View File

@@ -0,0 +1,92 @@
$(function () {
// request_time 为浮点秒decimal(13,3)),渲染为日期时间
var formatRequestTime = function (d) {
var t = parseFloat(d.request_time);
if (!t || isNaN(t)) {
return '-';
}
var dt = new Date(t * 1000);
var p = function (n) {
return n < 10 ? '0' + n : '' + n;
};
return dt.getFullYear() + '-' + p(dt.getMonth() + 1) + '-' + p(dt.getDate())
+ ' ' + p(dt.getHours()) + ':' + p(dt.getMinutes()) + ':' + p(dt.getSeconds());
};
ua.table.render({
init: init,
toolbar: [],
defaultToolbar: ['filter'],
cols: [[
{field: 'request_time', title: '请求时间', width: 170, sort: true, search: 'range', templet: formatRequestTime},
{
field: 'method', title: '方法', width: 90, search: 'select', selectList: {
GET: 'GET', POST: 'POST', PUT: 'PUT', DELETE: 'DELETE', HEAD: 'HEAD', OPTIONS: 'OPTIONS', PATCH: 'PATCH'
}, templet: function (d) {
var cls = {
GET: 'layui-bg-blue',
POST: 'layui-bg-green',
PUT: 'layui-bg-orange',
DELETE: 'layui-bg-red',
HEAD: 'layui-bg-gray',
OPTIONS: 'layui-bg-gray',
PATCH: 'layui-bg-cyan'
}[d.method];
if (d.method && cls) {
return '<span class="layui-badge ' + cls + '">' + d.method + '</span>';
}
return d.method || '-';
}
},
{
field: 'url', title: 'URL', minWidth: 220, searchOp: '%*%', templet: function (d) {
var u = d.url || '';
var short = u.length > 50 ? u.substring(0, 50) + '…' : u;
return '<span title="' + u.replace(/"/g, '&quot;') + '">' + (short === '' ? '/' : short) + '</span>';
}
},
{field: 'node_id', title: '节点', width: 160, search: 'select', searchOp: '=', selectList: ua.getDataBrage('select_list_node_id') || {}},
{
field: 'wall_time', title: '总耗时', width: 110, sort: true, searchOp: 'min', searchTip: '总耗时下限(ms)', templet: function (d) {
var ms = parseInt(d.wall_time, 10) / 1000;
var txt = ms.toFixed(1) + 'ms';
if (ms > 1000) {
return '<span class="layui-badge layui-bg-red">' + txt + '</span>';
}
return txt;
}
},
{
field: 'cpu_time', title: 'CPU耗时', width: 110, search: false, templet: function (d) {
var ms = parseInt(d.cpu_time, 10) / 1000;
var txt = ms.toFixed(1) + 'ms';
if (ms > 1000) {
return '<span class="layui-badge layui-bg-red">' + txt + '</span>';
}
return txt;
}
},
{
field: 'memory_peak', title: '内存峰值', width: 110, sort: true, search: false, templet: function (d) {
return (parseInt(d.memory_peak, 10) / 1048576).toFixed(2) + 'MB';
}
},
{
width: 100, title: '操作', fixed: 'right', templet: ua.table.tool, operat: [
[{
class: 'layui-btn layui-btn-primary layui-btn-xs',
method: 'open',
field: 'id',
text: '详情',
title: '采样详情(函数级分析)',
auth: 'detail',
url: init.detailUrl,
extend: 'data-full="true"'
}]
]
}
]]
});
ua.listen();
});

View File

@@ -0,0 +1,129 @@
<div class="layuimini-container detail-container">
<div class="layuimini-main">
<div class="layui-card detail-card">
<div class="layui-card-header detail-header">
<div class="layui-row">
<div class="layui-col-md9">
<h2 class="detail-title">#{$row.id} {$title}</h2>
<div class="detail-id">ID: {$row.id}</div>
</div>
<div class="layui-col-md3 text-right detail-actions">
<button class="layui-btn layui-btn-primary" layuimini-content-href="{$Request.param.backTagId}" data-back="1">返回</button>
<button class="layui-btn" onclick="location.href='{:url("edit", ["id" => $row.id])}'">编辑</button>
</div>
</div>
</div>
<div class="layui-card-body detail-content">
<div class="layui-row layui-col-space12">
<!-- 左侧主体内容 -->
<div class="layui-col-md8 detail-main">
<div class="detail-field-group">
<div class="detail-field-item">
<div class="detail-field-label">节点ID区分不同采集节点</div>
<div class="detail-field-value">
{notempty name="row.node_id"}
{$row.node_id}
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">请求完整 URL超长截断</div>
<div class="detail-field-value">
{notempty name="row.url"}
{$row.url}
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">规范化 URL query、数字段替换聚合用</div>
<div class="detail-field-value">
{notempty name="row.simple_url"}
{$row.simple_url}
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">HTTP 方法</div>
<div class="detail-field-value">
{notempty name="row.method"}
{$row.method}
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">总耗时微秒main wt</div>
<div class="detail-field-value">
{notempty name="row.wall_time"}
{$row.wall_time}
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">CPU 耗时微秒main cpu</div>
<div class="detail-field-value">
{notempty name="row.cpu_time"}
{$row.cpu_time}
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">内存峰值字节main pmu</div>
<div class="detail-field-value">
{notempty name="row.memory_peak"}
{$row.memory_peak}
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">请求开始时间REQUEST_TIME_FLOAT 浮点秒)</div>
<div class="detail-field-value">
{notempty name="row.request_time"}
{$row.request_time}
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
</div>
</div>
<!-- 右侧基础信息 -->
<div class="layui-col-md4 detail-side">
<h3 class="detail-side-title">基础信息</h3>
<div class="detail-field-group">
<div class="detail-field-item">
<div class="detail-field-label">ID</div>
<div class="detail-field-value">{$row.id}</div>
</div>
<div class="detail-field-item">
<div class="detail-field-label">入库时间戳</div>
<div class="detail-field-value">
{notempty name="row.create_time"}
{$row.create_time|date="Y-m-d H:i:s"}
{else/}
<span class="layui-text-em">暂无数据</span>
{/notempty}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
$(function(){
// 删除数据
window.deleteData = function(id) {
layer.confirm('确定要删除这条数据吗?', {
icon: 3,
title: '提示'
}, function(index) {
$.post('{{:url("delete")}}', {id: id}, function(res) {
if (res.code == 0) {
layer.msg('删除成功', {icon: 1}, function() {
location.href = '{{:url("index")}}';
});
} else {
layer.msg(res.msg, {icon: 2});
}
}, 'json');
layer.close(index);
});
};
ua.listen();
})