From 4544ef251675e43de8a02d81ba112f9410f8b370 Mon Sep 17 00:00:00 2001 From: iglocska Date: Wed, 17 Apr 2024 15:08:38 +0200 Subject: [PATCH] new: [benchmarking suite] added - collect metrics about the usage of MISP - stored in redis - per endpoint / user / user-agent collection - collection of execution time, php memory use, sql execution time, sql query count - the collection happens on a daily basis - Searchable / filterable interface for the collected data - Dashboard widget for the collected data --- app/Console/Command/AppShell.php | 14 +- app/Console/Command/ServerShell.php | 12 +- app/Controller/AppController.php | 34 +++- app/Controller/BenchmarksController.php | 123 ++++++++++++ app/Controller/Component/ACLComponent.php | 3 + app/Lib/Dashboard/BenchmarkTopListWidget.php | 115 +++++++++++ app/Lib/Tools/BenchmarkTool.php | 181 ++++++++++++++++++ app/Model/Benchmark.php | 6 + app/Model/Server.php | 7 + app/View/Benchmarks/index.ctp | 110 +++++++++++ .../dashboard/Widgets/MultiLineChart.ctp | 3 +- .../IndexTable/index_table.ctp | 2 +- .../genericElements/SideMenu/side_menu.ctp | 7 + app/View/Elements/global_menu.ctp | 5 + app/View/Users/view.ctp | 11 +- 15 files changed, 621 insertions(+), 12 deletions(-) create mode 100644 app/Controller/BenchmarksController.php create mode 100644 app/Lib/Dashboard/BenchmarkTopListWidget.php create mode 100644 app/Lib/Tools/BenchmarkTool.php create mode 100644 app/Model/Benchmark.php create mode 100644 app/View/Benchmarks/index.ctp diff --git a/app/Console/Command/AppShell.php b/app/Console/Command/AppShell.php index 6d63a94f4..869ae313d 100644 --- a/app/Console/Command/AppShell.php +++ b/app/Console/Command/AppShell.php @@ -18,6 +18,7 @@ App::uses('AppModel', 'Model'); App::uses('BackgroundJobsTool', 'Tools'); +App::uses('BenchmarkTool', 'Tools'); require_once dirname(__DIR__) . '/../Model/Attribute.php'; // FIXME workaround bug where Vendor/symfony/polyfill-php80/Resources/stubs/Attribute.php is loaded instead @@ -38,7 +39,18 @@ abstract class AppShell extends Shell { $configLoad = $this->Tasks->load('ConfigLoad'); $configLoad->execute(); - + if (Configure::read('Plugin.Benchmarking_enable')) { + $Benchmark = new BenchmarkTool(ClassRegistry::init('User')); + $start_time = $Benchmark->startBenchmark(); + register_shutdown_function(function () use ($start_time, $Benchmark) { + $Benchmark->stopBenchmark([ + 'user' => 0, + 'controller' => 'Shell::' . $this->modelClass, + 'action' => $this->command, + 'start_time' => $start_time + ]); + }); + } parent::initialize(); } diff --git a/app/Console/Command/ServerShell.php b/app/Console/Command/ServerShell.php index ad2d8df72..6b29266f4 100644 --- a/app/Console/Command/ServerShell.php +++ b/app/Console/Command/ServerShell.php @@ -125,7 +125,6 @@ class ServerShell extends AppShell if (empty($this->args[0]) || empty($this->args[1])) { die('Usage: ' . $this->Server->command_line_functions['console_automation_tasks']['data']['Pull'] . PHP_EOL); } - $userId = $this->args[0]; $user = $this->getUser($userId); $serverId = $this->args[1]; @@ -166,7 +165,7 @@ class ServerShell extends AppShell if (empty($this->args[0]) || empty($this->args[1])) { die('Usage: ' . $this->Server->command_line_functions['console_automation_tasks']['data']['Push'] . PHP_EOL); } - + $userId = $this->args[0]; $user = $this->getUser($userId); $serverId = $this->args[1]; @@ -370,7 +369,7 @@ class ServerShell extends AppShell if (empty($this->args[0]) || empty($this->args[1])) { die('Usage: ' . $this->Server->command_line_functions['console_automation_tasks']['data']['Fetch feeds as local data'] . PHP_EOL); } - + $userId = $this->args[0]; $user = $this->getUser($userId); $feedId = $this->args[1]; @@ -426,7 +425,7 @@ class ServerShell extends AppShell if (empty($this->args[0]) || empty($this->args[1])) { die('Usage: ' . $this->Server->command_line_functions['console_automation_tasks']['data']['Cache server'] . PHP_EOL); } - + $userId = $this->args[0]; $user = $this->getUser($userId); $scope = $this->args[1]; @@ -489,7 +488,7 @@ class ServerShell extends AppShell if (empty($this->args[0]) || empty($this->args[1])) { die('Usage: ' . $this->Server->command_line_functions['console_automation_tasks']['data']['Cache feeds for quick lookups'] . PHP_EOL); } - + $userId = $this->args[0]; $user = $this->getUser($userId); $scope = $this->args[1]; @@ -735,6 +734,7 @@ class ServerShell extends AppShell public function sendPeriodicSummaryToUsers() { + $periods = $this->__getPeriodsForToday(); $start_time = time(); echo __n('Started periodic summary generation for the %s period', 'Started periodic summary generation for periods: %s', count($periods), implode(', ', $periods)) . PHP_EOL; @@ -800,7 +800,7 @@ class ServerShell extends AppShell if (empty($this->args[0]) || empty($this->args[1])) { die('Usage: ' . $this->Server->command_line_functions['console_automation_tasks']['data']['Push Taxii'] . PHP_EOL); } - + $userId = $this->args[0]; $user = $this->getUser($userId); $serverId = $this->args[1]; diff --git a/app/Controller/AppController.php b/app/Controller/AppController.php index 56486d44f..010af593b 100755 --- a/app/Controller/AppController.php +++ b/app/Controller/AppController.php @@ -40,6 +40,12 @@ class AppController extends Controller public $phptoonew = '8.0'; private $isApiAuthed = false; + /** @var redis */ + private $redis = null; + + /** @var benchmark_results */ + private $benchmark_results = null; + public $baseurl = ''; public $restResponsePayload = null; @@ -57,9 +63,14 @@ class AppController extends Controller /** @var ACLComponent */ public $ACL; + /** @var BenchmarkComponent */ + public $Benchmark; + /** @var RestResponseComponent */ public $RestResponse; + public $start_time; + public function __construct($request = null, $response = null) { parent::__construct($request, $response); @@ -97,6 +108,12 @@ class AppController extends Controller public function beforeFilter() { + $this->User = ClassRegistry::init('User'); + if (Configure::read('Plugin.Benchmarking_enable')) { + App::uses('BenchmarkTool', 'Tools'); + $this->Benchmark = new BenchmarkTool($this->User); + $this->start_time = $this->Benchmark->startBenchmark(); + } $controller = $this->request->params['controller']; $action = $this->request->params['action']; @@ -147,8 +164,6 @@ class AppController extends Controller Configure::write('Config.language', 'eng'); } - $this->User = ClassRegistry::init('User'); - if (!empty($this->request->params['named']['disable_background_processing'])) { Configure::write('MISP.background_jobs', 0); } @@ -863,6 +878,21 @@ class AppController extends Controller public function afterFilter() { + // benchmarking + if (Configure::read('Plugin.Benchmarking_enable')) { + $this->Benchmark->stopBenchmark([ + 'user' => $this->Auth->user('id'), + 'controller' => $this->request->params['controller'], + 'action' => $this->request->params['action'], + 'start_time' => $this->start_time + ]); + + //if ($redis && !$redis->exists('misp:auth_fail_throttling:' . $key)) { + //$redis->setex('misp:auth_fail_throttling:' . $key, 3600, 1); + //return true; + //} + + } if ($this->isApiAuthed && $this->_isRest() && !Configure::read('Security.authkey_keep_session')) { $this->Session->destroy(); } diff --git a/app/Controller/BenchmarksController.php b/app/Controller/BenchmarksController.php new file mode 100644 index 000000000..6fe4ce773 --- /dev/null +++ b/app/Controller/BenchmarksController.php @@ -0,0 +1,123 @@ + 60, + 'maxLimit' => 9999, + + ]; + + public function beforeFilter() + { + parent::beforeFilter(); + } + + public function index() + { + $this->set('menuData', ['menuList' => 'admin', 'menuItem' => 'index']); + $this->loadModel('User'); + App::uses('BenchmarkTool', 'Tools'); + $this->Benchmark = new BenchmarkTool($this->User); + $passedArgs = $this->passedArgs; + $this->paginate['order'] = 'value'; + $defaults = [ + 'days' => null, + 'average' => false, + 'aggregate' => false, + 'scope' => null, + 'field' => null, + 'key' => null, + 'quickFilter' => null + ]; + $filters = $this->IndexFilter->harvestParameters(array_keys($defaults)); + foreach ($defaults as $key => $value) { + if (!isset($filters[$key])) { + $filters[$key] = $defaults[$key]; + } + } + $temp = $this->Benchmark->getAllTopLists( + $filters['days'] ?? null, + $filters['limit'] ?? 100, + $filters['average'] ?? null, + $filters['aggregate'] ?? null + ); + $settings = $this->Benchmark->getSettings(); + $units = $this->Benchmark->getUnits(); + $this->set('settings', $settings); + $data = []; + $userLookup = []; + foreach ($temp as $scope => $t) { + if (!empty($filters['scope']) && $filters['scope'] !== 'all' && $scope !== $filters['scope']) { + continue; + } + foreach ($t as $field => $t2) { + if (!empty($filters['field']) && $filters['field'] !== 'all' && $field !== $filters['field']) { + continue; + } + foreach ($t2 as $date => $t3) { + foreach ($t3 as $key => $value) { + if ($scope == 'user') { + if ($key === 'SYSTEM') { + $text = 'SYSTEM'; + } else if (isset($userLookup[$key])) { + $text = $userLookup[$key]; + } else { + $user = $this->User->find('first', [ + 'fields' => ['User.id', 'User.email'], + 'recursive' => -1, + 'conditions' => ['User.id' => $key] + ]); + if (empty($user)) { + $text = '(' . $key . ') ' . __('Invalid user'); + } else { + $text = '(' . $key . ') ' . $user['User']['email']; + } + $userLookup[$key] = $text; + } + } else { + $text = $key; + } + if (!empty($filters['quickFilter'])) { + $q = strtolower($filters['quickFilter']); + if ( + strpos(strtolower($scope), $q) === false && + strpos(strtolower($field), $q) === false && + strpos(strtolower($key), $q) === false && + strpos(strtolower($value), $q) === false && + strpos(strtolower($date), $q) === false && + strpos(strtolower($text), $q) === false + ) { + continue; + } + } + if (empty($filters['key']) || $key == $filters['key']) { + $data[] = [ + 'scope' => $scope, + 'field' => $field, + 'date' => $date, + 'key' => $key, + 'text' => $text, + 'value' => $value, + 'unit' => $units[$field] + ]; + } + } + } + } + } + if ($this->_isRest()) { + return $this->RestResponse->viewData($data, $this->response->type()); + } + App::uses('CustomPaginationTool', 'Tools'); + $customPagination = new CustomPaginationTool(); + $customPagination->truncateAndPaginate($data, $this->params, $this->modelClass, true); + $this->set('data', $data); + $this->set('passedArgs', json_encode($passedArgs)); + $this->set('filters', $filters); + } + +} diff --git a/app/Controller/Component/ACLComponent.php b/app/Controller/Component/ACLComponent.php index cfe7147a9..e121f5c5e 100644 --- a/app/Controller/Component/ACLComponent.php +++ b/app/Controller/Component/ACLComponent.php @@ -95,6 +95,9 @@ class ACLComponent extends Component 'index' => ['perm_auth'], 'view' => ['perm_auth'], ], + 'benchmarks' => [ + 'index' => [] + ], 'cerebrates' => [ 'add' => [], 'delete' => [], diff --git a/app/Lib/Dashboard/BenchmarkTopListWidget.php b/app/Lib/Dashboard/BenchmarkTopListWidget.php new file mode 100644 index 000000000..1fd36ec1b --- /dev/null +++ b/app/Lib/Dashboard/BenchmarkTopListWidget.php @@ -0,0 +1,115 @@ + 'Number of days to consider for the graph. There will be a data entry for each day (assuming the benchmarking has been enabled). Defaults to returning all data.', + 'weeks' => 'Number of weeks to consider for the graph. There will be a data entry for each day (assuming the benchmarking has been enabled). Defaults to returning all data.', + 'months' => 'Number of months to consider for the graph. There will be a data entry for each day (assuming the benchmarking has been enabled). Defaults to returning all data.', + 'scope' => 'The scope of the benchmarking refers to what was being tracked. The following scopes are valid: user, endpoint, user_agent', + 'field' => 'The individual metric to be queried from the benchmark results. Valid values are: time, sql_time, sql_queries, memory, endpoint', + 'average' => 'If you wish to view the averages per scope/field, set this variable to true. It will divide the result by the number of executions recorded for the scope/field combination for the given day.' + ); + public $Benchmark; + public $User; + + public $placeholder = + '{ + "days": "30", + "scope": "endpoints", + "field": "sql_time" +}'; + + public function handler($user, $options = array()) + { + $this->User = ClassRegistry::init('User'); + $currentTime = strtotime("now"); + $endOfDay = strtotime("tomorrow", $currentTime) - 1; + if (!empty($options['days'])) { + $limit = (int)($options['days']); + $delta = 'day'; + } else if (!empty($options['weeks'])) { + $limit = (int)($options['weeks']); + $delta = 'week'; + } else if (!empty($options['months'])) { + $limit = (int)($options['months']); + $delta = 'month'; + } else { + $limit = 30; + $delta = 'day'; + } + $axis_info = [ + 'time' => 'Total time taken (ms)', + 'sql_time' => 'SQL time taken (ms)', + 'sql_queries' => 'Queries (#)', + 'memory' => 'Memory (MB)', + 'endpoint' => 'Queries to endpoint (#)' + ]; + $y_axis = $axis_info[isset($options['field']) ? $options['field'] : 'time']; + $data = ['y-axis' => $y_axis]; + $data['data'] = array(); + // Add total users data for all timestamps + + for ($i = 0; $i < $limit; $i++) { + $itemTime = strtotime('- ' . $i . $delta, $endOfDay); + $item = array(); + $date = strftime('%Y-%m-%d', $itemTime); + $item = $this->getData($date, $options); + if (!empty($item)) { + $item['date'] = $date; + $data['data'][] = $item; + } + } + $keys = []; + foreach ($data['data'] as $day_data) { + foreach ($day_data as $key => $temp) { + $keys[$key] = 1; + } + } + $keys = array_keys($keys); + foreach ($data['data'] as $k => $day_data) { + foreach ($keys as $key) { + if (!isset($day_data[$key])) { + $data['data'][$k][$key] = 0; + } + } + foreach ($day_data as $key => $temp) { + $keys[$key] = 1; + } + } + return $data; + } + + private function getData($time, $options) + { + $dates = [$time]; + $this->Benchmark = new BenchmarkTool($this->User); + $result = $this->Benchmark->getTopList( + isset($options['scope']) ? $options['scope'] : 'endpoint', + isset($options['field']) ? $options['field'] : 'memory', + $dates, + isset($options['limit']) ? $options['limit'] : 5, + isset($options['average']) ? $options['average'] : false, + ); + if (!empty($result)) { + return $result[$time]; + } + return false; + } + + public function checkPermissions($user) + { + if (empty($user['Role']['perm_site_admin'])) { + return false; + } + return true; + } +} diff --git a/app/Lib/Tools/BenchmarkTool.php b/app/Lib/Tools/BenchmarkTool.php new file mode 100644 index 000000000..8f741b854 --- /dev/null +++ b/app/Lib/Tools/BenchmarkTool.php @@ -0,0 +1,181 @@ + 's', + 'sql_time' => 'ms', + 'sql_queries' => '', + 'memory' => 'MB' + ]; + + public $namespace = 'misp:benchmark:'; + + function __construct(Model $model) { + $this->Model = $model; + } + + public function getSettings() + { + return [ + 'scope' => self::BENCHMARK_SCOPES, + 'field' => self::BENCHMARK_FIELDS, + 'average' => [0, 1], + 'aggregate' => [0, 1] + ]; + } + + public function getUnits() + { + return self::BENCHMARK_UNITS; + } + + public function startBenchmark() + { + $start_time = microtime(true); + $this->redis = $this->Model->setupRedis(); + $this->retention = Configure::check('Plugin.benchmark_retention') ? Configure::read('Plugin.benchmark_retention') : 0; + return $start_time; + } + + public function stopBenchmark(array $options) + { + $start_time = $options['start_time']; + if (!empty($options['user'])) { + $sql = $this->Model->getDataSource()->getLog(false, false); + $benchmarkData = [ + 'user' => $options['user'], + 'endpoint' => $options['controller'] . '/' . $options['action'], + 'user_agent' => $_SERVER['HTTP_USER_AGENT'], + 'sql_queries' => $sql['count'], + 'sql_time' => $sql['time'], + 'time' => (microtime(true) - $start_time), + 'memory' => (int)(memory_get_peak_usage(true) / 1024 / 1024), + //'date' => date('Y-m-d', strtotime("-3 days")) + 'date' => date('Y-m-d') + ]; + $this->pushBenchmarkDataToRedis($benchmarkData); + } else { + $sql = $this->Model->getDataSource()->getLog(false, false); + $benchmarkData = [ + 'user' => 'SYSTEM', + 'endpoint' => $options['controller'] . '/' . $options['action'], + 'user_agent' => 'CLI', + 'sql_queries' => $sql['count'], + 'sql_time' => $sql['time'], + 'time' => (microtime(true) - $start_time), + 'memory' => (int)(memory_get_peak_usage(true) / 1024 / 1024), + //'date' => date('Y-m-d', strtotime("-3 days")) + 'date' => date('Y-m-d') + ]; + $this->pushBenchmarkDataToRedis($benchmarkData); + } + } + + private function pushBenchmarkDataToRedis($benchmarkData) + { + $this->redis = $this->Model->setupRedis(); + $this->redis->pipeline(); + $this->redis->sAdd( + $this->namespace . 'days', + $benchmarkData['date'] + ); + foreach (self::BENCHMARK_SCOPES as $scope) { + $this->redis->sAdd( + $this->namespace . $scope . ':list', + $benchmarkData[$scope] + ); + $this->redis->zIncrBy( + $this->namespace . $scope . ':count:' . $benchmarkData['date'], + 1, + $benchmarkData[$scope] + ); + foreach (self::BENCHMARK_FIELDS as $field) { + $this->redis->zIncrBy( + $this->namespace . $scope . ':' . $field . ':' . $benchmarkData['date'], + $benchmarkData[$field], + $benchmarkData[$scope] + ); + } + $this->redis->zIncrBy( + $this->namespace . $scope . ':endpoint:' . $benchmarkData['date'] . ':' . $benchmarkData['user'], + 1, + $benchmarkData['endpoint'] + ); + } + $this->redis->exec(); + } + + public function getTopList(string $scope, string $field, array $days = [], $limit = 10, $average = false, $aggregate = false) + { + if (empty($this->redis)) { + $this->redis = $this->Model->setupRedis(); + } + $results = []; + if (is_string($days)) { + $days = [$days]; + } + foreach ($days as $day) { + $temp = $this->redis->zrevrange($this->namespace . $scope . ':' . $field . ':' . $day, 0, $limit, true); + foreach ($temp as $k => $v) { + if ($average) { + $divisor = $this->redis->zscore($this->namespace . $scope . ':count:' . $day, $k); + if ($aggregate) { + $results['aggregate'][$k] = empty($results['aggregate'][$k]) ? ($v / $divisor) : ($results['aggregate'][$k] + ($v / $divisor)); + } else { + $results[$day][$k] = (int)($v / $divisor); + } + } else { + if ($aggregate) { + $results['aggregate'][$k] = empty($results['aggregate'][$k]) ? $v : ($results['aggregate'][$k] + $v); + } else { + $results[$day][$k] = $v; + } + } + } + } + if ($aggregate && $average) { + $count_days = count($days); + foreach ($results['aggregate'] as $k => $result) { + $results['aggregate'][$k] = (int)($result / $count_days); + } + } + return $results; + } + + public function getAllTopLists(array $days = null, $limit = 10, $average = false, $aggregate = false, $scope_filter = []) + { + if (empty($this->redis)) { + $this->redis = $this->Model->setupRedis(); + } + if ($days === null) { + $days = $this->redis->smembers($this->namespace . 'days'); + } + foreach (self::BENCHMARK_SCOPES as $scope) { + if (empty($scope_filter) || in_array($scope, $scope_filter)) { + foreach (self::BENCHMARK_FIELDS as $field) { + $results[$scope][$field] = $this->getTopList($scope, $field, $days, $limit, $average, $aggregate); + } + } + } + return $results; + } +} diff --git a/app/Model/Benchmark.php b/app/Model/Benchmark.php new file mode 100644 index 000000000..c350c6398 --- /dev/null +++ b/app/Model/Benchmark.php @@ -0,0 +1,6 @@ + 'testBool', 'type' => 'boolean' ), + 'Benchmarking_enable' => [ + 'level' => 2, + 'description' => __('Enable the benchmarking functionalities to capture information about execution times, SQL query loads and more per user and per endpoint.'), + 'value' => false, + 'test' => 'testBool', + 'type' => 'boolean' + ], 'Enrichment_services_enable' => array( 'level' => 0, 'description' => __('Enable/disable the enrichment services'), diff --git a/app/View/Benchmarks/index.ctp b/app/View/Benchmarks/index.ctp new file mode 100644 index 000000000..df2c9e282 --- /dev/null +++ b/app/View/Benchmarks/index.ctp @@ -0,0 +1,110 @@ + __('Date'), + 'sort' => 'date', + 'data_path' => 'date' + ], + [ + 'name' => __('scope'), + 'sort' => 'scope', + 'data_path' => 'scope' + ], + [ + 'name' => __('Key'), + 'sort' => 'key', + 'data_path' => 'text' + ], + [ + 'name' => __('field'), + 'sort' => 'field', + 'data_path' => 'field' + ], + [ + 'name' => __('Value'), + 'element' => 'custom', + 'function' => function($row) { + return empty($row['unit']) ? h($row['value']) : h($row['value'] . ' ' . $row['unit']); + }, + 'sort' => 'value' + ] + ]; + $quick_filters = []; + foreach ($settings as $key => $setting_data) { + $temp = $filters; + $url = $baseurl . '/benchmarks/index'; + foreach ($temp as $s => $v) { + if ($v && $s != $key) { + if (is_array($v)) { + foreach ($v as $multi_v) { + $url .= '/' . $s . '[]:' . $multi_v; + } + } else { + $url .= '/' . $s . ':' . $v; + } + + } + } + if ($key != 'average' && $key != 'aggregate') { + $quick_filters[$key]['all'] = [ + 'url' => h($url), + 'text' => __('All'), + 'active' => !$filters[$key], + 'style' => 'display:inline;' + ]; + } + foreach ($setting_data as $setting_element) { + $text = $setting_element; + if ($key == 'average') { + $text = $setting_element ? 'average / request' : 'total'; + } + if ($key == 'aggregate') { + $text = $setting_element ? 'aggregate' : 'daily'; + } + $quick_filters[$key][] = [ + 'url' => h($url . '/' . $key . ':' . $setting_element), + 'text' => $text, + 'active' => $filters[$key] == $setting_element, + 'style' => 'display:inline;' + ]; + } + } + echo $this->element('genericElements/IndexTable/scaffold', [ + 'scaffold_data' => [ + 'passedArgsArray' => $passedArgsArray, + 'data' => [ + 'persistUrlParams' => array_keys($settings), + 'data' => $data, + 'top_bar' => [ + 'pull' => 'right', + 'children' => [ + [ + 'children' => $quick_filters['scope'] + ], + [ + 'children' => $quick_filters['field'] + ], + [ + 'children' => $quick_filters['average'] + ], + [ + 'children' => $quick_filters['aggregate'] + ], + [ + 'type' => 'search', + 'button' => __('Filter'), + 'placeholder' => __('Enter value to search'), + 'data' => '', + 'searchKey' => 'quickFilter' + ] + ] + ], + 'fields' => $fields, + 'title' => empty($ajax) ? __('Benchmark results') : false, + 'description' => empty($ajax) ? __('Results of the collected benchmarks. You can filter it further by passing the limit, scope, field parameters.') : false, + ] + ] + ]); + +?> diff --git a/app/View/Elements/dashboard/Widgets/MultiLineChart.ctp b/app/View/Elements/dashboard/Widgets/MultiLineChart.ctp index a6057be90..10115e48d 100644 --- a/app/View/Elements/dashboard/Widgets/MultiLineChart.ctp +++ b/app/View/Elements/dashboard/Widgets/MultiLineChart.ctp @@ -6,6 +6,7 @@ h($data['formula']) ); } + $y_axis = $data['y-axis'] ?? 'Count'; ?>