Replace jqPlot with Chart.js in /server/status/queries

Signed-off-by: Maurício Meneghini Fauth <mauricio@fauth.dev>
This commit is contained in:
Maurício Meneghini Fauth 2023-04-06 18:52:43 -03:00
parent 35874a1172
commit 62d16a07ee
No known key found for this signature in database
GPG Key ID: 6A16FD38AFC89CC8
18 changed files with 147 additions and 310 deletions

View File

@ -42,11 +42,11 @@ ChartFactory.prototype = {
* @param elementId
* id of the div element the chart is drawn in
*/
var Chart = function (elementId = undefined) {
var AbstractChart = function (elementId = undefined) {
this.elementId = elementId;
};
Chart.prototype = {
AbstractChart.prototype = {
draw: function () {
throw new Error('draw must be implemented by a subclass');
},
@ -74,10 +74,10 @@ Chart.prototype = {
* id of the div element the chart is drawn in
*/
var BaseChart = function (elementId = undefined) {
Chart.call(this, elementId);
AbstractChart.call(this, elementId);
};
BaseChart.prototype = new Chart();
BaseChart.prototype = new AbstractChart();
BaseChart.prototype.constructor = BaseChart;
BaseChart.prototype.validateColumns = function (dataTable) {
var columns = dataTable.getColumns();
@ -220,12 +220,12 @@ var DataTable = function () {
* id of the div element the chart is drawn in
*/
var JQPlotChart = function (elementId = undefined) {
Chart.call(this, elementId);
AbstractChart.call(this, elementId);
this.plot = null;
this.validator = null;
};
JQPlotChart.prototype = new Chart();
JQPlotChart.prototype = new AbstractChart();
JQPlotChart.prototype.constructor = JQPlotChart;
JQPlotChart.prototype.draw = function (data, options) {
if (this.validator.validateColumns(data)) {

View File

@ -1,43 +1,37 @@
import $ from 'jquery';
import { AJAX } from '../../modules/ajax.ts';
import createProfilingChart from '../../modules/functions/createProfilingChart.ts';
/**
* @fileoverview Javascript functions used in server status query page
* @name Server Status Query
*
* @requires jQueryUI
*/
/**
* Unbind all event handlers before tearing down a page
*/
AJAX.registerTeardown('server/status/queries.js', function () {
if (document.getElementById('serverstatusquerieschart') !== null) {
var queryPieChart = $('#serverstatusquerieschart').data('queryPieChart');
if (queryPieChart) {
queryPieChart.destroy();
}
function buildQueryStatsChart (): void {
const queryStatisticsChartCanvas = document.getElementById('query-statistics-chart') as HTMLCanvasElement;
if (! queryStatisticsChartCanvas) {
return;
}
});
AJAX.registerOnload('server/status/queries.js', function () {
// Build query statistics chart
var cdata = [];
const chartDataJson = queryStatisticsChartCanvas.getAttribute('data-chart-data');
let chartData = null;
try {
if (document.getElementById('serverstatusquerieschart') !== null) {
$.each($('#serverstatusquerieschart').data('chart'), function (key, value) {
cdata.push([key, parseInt(value, 10)]);
});
$('#serverstatusquerieschart').data(
'queryPieChart',
createProfilingChart('serverstatusquerieschart', cdata)
);
}
} catch (exception) {
// Could not load chart, no big deal...
chartData = JSON.parse(chartDataJson);
} catch (e) {
return;
}
window.initTableSorter('statustabs_queries');
if (! (chartData && 'labels' in chartData && 'data' in chartData)) {
return;
}
new window.Chart(queryStatisticsChartCanvas, {
type: 'pie',
data: {
labels: chartData.labels,
datasets: [
{
label: window.Messages.numberOfStatements,
data: chartData.data,
}
],
},
});
}
AJAX.registerOnload('server/status/queries.js', function (): void {
buildQueryStatsChart();
});

View File

@ -1,78 +0,0 @@
import $ from 'jquery';
// TODO: tablesorter shouldn't sort already sorted columns
// eslint-disable-next-line no-unused-vars
function initTableSorter (tabid) {
var $table;
var opts;
switch (tabid) {
case 'statustabs_queries':
$table = $('#serverStatusQueriesDetails');
opts = {
sortList: [[3, 1]],
headers: {
1: { sorter: 'fancyNumber' },
2: { sorter: 'fancyNumber' }
}
};
break;
}
$table.tablesorter(opts);
$table.find('tr').first().find('th')
.append('<div class="sorticon"></div>');
}
window.initTableSorter = initTableSorter;
$(function () {
$.tablesorter.addParser({
id: 'fancyNumber',
is: function (s) {
return (/^[0-9]?[0-9,\\.]*\s?(k|M|G|T|%)?$/).test(s);
},
format: function (s) {
var num = $.tablesorter.formatFloat(
s.replace(window.Messages.strThousandsSeparator, '')
.replace(window.Messages.strDecimalSeparator, '.')
);
var factor = 1;
switch (s.charAt(s.length - 1)) {
case '%':
factor = -2;
break;
// Todo: Complete this list (as well as in the regexp a few lines up)
case 'k':
factor = 3;
break;
case 'M':
factor = 6;
break;
case 'G':
factor = 9;
break;
case 'T':
factor = 12;
break;
}
return num * Math.pow(10, factor);
},
type: 'numeric'
});
$.tablesorter.addParser({
id: 'withinSpanNumber',
is: function (s) {
return (/<span class="original"/).test(s);
},
format: function (s, table, html) {
var res = html.innerHTML.match(/<span(\s*style="display:none;"\s*)?\s*class="original">(.*)?<\/span>/);
return (res && res.length >= 3) ? res[2] : 0;
},
type: 'numeric'
});
});

View File

@ -170,6 +170,8 @@ final class JavaScriptMessagesController
'strChartConnectionsTitle' => __('Connections / Processes'),
'numberOfStatements' => __('Number of statements'),
/* server status monitor */
'strIncompatibleMonitorConfig' => __('Local monitor configuration incompatible!'),
'strIncompatibleMonitorConfigDescription' => __(

View File

@ -47,7 +47,6 @@ class MonitorController extends AbstractController
'vendor/jqplot/plugins/jqplot.cursor.js',
'jqplot/plugins/jqplot.byteFormatter.js',
'server/status/monitor.js',
'server/status/sorter.js',
'chart.js',// Needed by createProfilingChart in server/status/monitor.js
]);

View File

@ -15,11 +15,16 @@ use PhpMyAdmin\Template;
use PhpMyAdmin\Url;
use function __;
use function array_keys;
use function array_sum;
use function array_values;
use function arsort;
use function count;
use function mb_convert_case;
use function str_replace;
use const MB_CASE_TITLE;
class QueriesController extends AbstractController
{
public function __construct(
@ -39,17 +44,9 @@ class QueriesController extends AbstractController
$this->dbi->selectDb('mysql');
}
$this->addScriptFiles([
'chart.js',
'vendor/jqplot/jquery.jqplot.js',
'vendor/jqplot/plugins/jqplot.pieRenderer.js',
'vendor/jqplot/plugins/jqplot.highlighter.js',
'vendor/jqplot/plugins/jqplot.enhancedPieLegendRenderer.js',
'vendor/jquery/jquery.tablesorter.js',
'server/status/sorter.js',
'server/status/queries.js',
]);
$this->addScriptFiles(['vendor/chart.umd.js', 'server/status/queries.js']);
$chart = [];
if ($this->data->dataLoaded) {
$hourFactor = 3600 / $this->data->status['Uptime'];
$usedQueries = $this->data->usedQueries;
@ -65,7 +62,6 @@ class QueriesController extends AbstractController
// reverse sort by value to show most used statements first
arsort($usedQueries);
$chart = [];
$querySum = array_sum($usedQueries);
$otherSum = 0;
$queries = [];
@ -73,13 +69,13 @@ class QueriesController extends AbstractController
// For the percentage column, use Questions - Connections, because
// the number of connections is not an item of the Query types
// but is included in Questions. Then the total of the percentages is 100.
$name = str_replace(['Com_', '_'], ['', ' '], $key);
$name = mb_convert_case(str_replace(['Com_', '_'], ['', ' '], $key), MB_CASE_TITLE);
// Group together values that make out less than 2% into "Other", but only
// if we have more than 6 fractions already
if ($value < $querySum * 0.02 && count($chart) > 6) {
$otherSum += $value;
} else {
$chart[$name] = $value;
$chart[$name] = (int) $value;
}
$queries[$key] = [
@ -91,15 +87,17 @@ class QueriesController extends AbstractController
}
if ($otherSum > 0) {
$chart[__('Other')] = $otherSum;
$chart[__('Other statements')] = $otherSum;
}
}
$chartData = ['labels' => array_keys($chart), 'data' => array_values($chart)];
$this->render('server/status/queries/index', [
'is_data_loaded' => $this->data->dataLoaded,
'stats' => $stats ?? null,
'queries' => $queries ?? [],
'chart' => $chart ?? [],
'chart_data' => $chartData,
]);
}
}

View File

@ -48,7 +48,6 @@ class VariablesController extends AbstractController
$this->addScriptFiles([
'server/status/variables.js',
'vendor/jquery/jquery.tablesorter.js',
'server/status/sorter.js',
]);
$flush = $request->getParsedBodyParam('flush');

View File

@ -17,6 +17,7 @@
"@babel/preset-typescript": "^7.21.0",
"@popperjs/core": "^2.11.7",
"@types/bootstrap": "^5.2.6",
"@types/chart.js": "^2.9.37",
"@types/codemirror": "^5.60.7",
"@types/jquery": "^3.5.16",
"@types/jquery.ui.datetimepicker": "^0.3.30",
@ -27,6 +28,7 @@
"autoprefixer": "^10.4.14",
"babel-loader": "^9.1.2",
"bootstrap": "5.3.0-alpha2",
"chart.js": "^4.2.1",
"codemirror": "5.65.12",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.6.0",

View File

@ -1950,6 +1950,11 @@ parameters:
count: 2
path: libraries/classes/Controllers/Server/Status/ProcessesController.php
-
message: "#^Cannot cast mixed to int\\.$#"
count: 1
path: libraries/classes/Controllers/Server/Status/QueriesController.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, \\(int\\|string\\) given\\.$#"
count: 1

View File

@ -2987,8 +2987,7 @@
<code>$key</code>
</MixedArgumentTypeCoercion>
<MixedAssignment>
<code>$chart[$name]</code>
<code><![CDATA[$chart[__('Other')]]]></code>
<code><![CDATA[$chart[__('Other statements')]]]></code>
<code>$hourFactor</code>
<code>$otherSum</code>
<code>$value</code>

View File

@ -607,11 +607,6 @@ div#tablestatistics table {
cursor: pointer;
}
h3#serverstatusqueries span {
font-size: 60%;
display: inline;
}
div#serverStatusTabs {
margin-top: 1em;
}
@ -620,13 +615,6 @@ caption a.top {
float: right;
}
div#serverstatusquerieschart {
float: left;
width: 500px;
height: 350px;
margin-right: 50px;
}
div {
&#serverstatus table {
tbody td.descr a,
@ -2666,11 +2654,6 @@ body .ui-dialog {
width: 98%;
}
div#serverstatusquerieschart {
width: 100%;
height: 450px;
}
.ui-dialog {
margin: 1%;
width: 95% !important;

View File

@ -817,11 +817,6 @@ div#tablestatistics table {
cursor: pointer;
}
h3#serverstatusqueries span {
font-size: 60%;
display: inline;
}
div#serverStatusTabs {
margin-top: 1em;
}
@ -830,13 +825,6 @@ caption a.top {
float: right;
}
div#serverstatusquerieschart {
float: left;
width: 500px;
height: 350px;
padding-left: 30px;
}
div {
&#serverstatus table {
tbody td.descr a,

View File

@ -638,11 +638,6 @@ div#tablestatistics table {
cursor: pointer;
}
h3#serverstatusqueries span {
font-size: 60%;
display: inline;
}
div#serverStatusTabs {
margin-top: 1em;
}
@ -651,15 +646,6 @@ caption a.top {
float: right;
}
div {
&#serverstatusquerieschart {
float: left;
width: 500px;
height: 350px;
margin-right: 50px;
}
}
div {
&#serverstatus table {
tbody td.descr a,
@ -2672,11 +2658,6 @@ body {
width: 98%;
}
div#serverstatusquerieschart {
width: 100%;
height: 450px;
}
.ui-dialog {
margin: 1%;
width: 95% !important;

View File

@ -780,11 +780,6 @@ div#tablestatistics table {
cursor: pointer;
}
h3#serverstatusqueries span {
font-size: 60%;
display: inline;
}
div#serverStatusTabs {
margin-top: 1em;
}
@ -793,13 +788,6 @@ caption a.top {
float: right;
}
div#serverstatusquerieschart {
float: left;
width: 500px;
height: 350px;
margin-right: 50px;
}
div {
&#serverstatus table {
tbody td.descr a,
@ -2802,11 +2790,6 @@ body .ui-dialog {
width: 98%;
}
div#serverstatusquerieschart {
width: 100%;
height: 450px;
}
.ui-dialog {
margin: 1%;
width: 95% !important;

View File

@ -1,62 +1,57 @@
{% extends 'server/status/base.twig' %}
{% set active = 'queries' %}
{% block content %}
<div class="container">
{% if not is_data_loaded %}
{{ 'Not enough privilege to view query statistics.'|trans|error }}
{% else %}
<h3>
{% trans %}Questions since startup:{% notes %}Questions is the name of a MySQL Status variable{% endtrans %} {{ format_number(stats.total, 0) }}
{{ show_mysql_docu('server-status-variables', false, null, null, 'statvar_Questions') }}
</h3>
{% if is_data_loaded %}
<div class="row">
<h3 id="serverstatusqueries">
{% trans %}
Questions since startup:
{% notes %}
Questions is the name of a MySQL Status variable
{% endtrans %}
{{ format_number(stats.total, 0) }}
{{ show_mysql_docu('server-status-variables', false, null, null, 'statvar_Questions') }}
</h3>
</div>
<div class="row">
<ul>
<li>ø {% trans 'per hour:' %} {{ format_number(stats.per_hour, 0) }}</li>
<li>ø {% trans 'per minute:' %} {{ format_number(stats.per_minute, 0) }}</li>
{% if stats.per_second >= 1 %}
<li>ø {% trans 'per second:' %} {{ format_number(stats.per_second, 0) }}</li>
{% endif %}
</ul>
</div>
<div class="row">
<ul>
<li>ø {% trans 'per hour:' %} {{ format_number(stats.per_hour, 0) }}</li>
<li>ø {% trans 'per minute:' %} {{ format_number(stats.per_minute, 0) }}</li>
{% if stats.per_second >= 1 %}
<li>ø {% trans 'per second:' %} {{ format_number(stats.per_second, 0) }}</li>
<div class="row">
<table id="serverStatusQueriesDetails" class="table table-striped table-hover col">
<colgroup>
<col class="namecol">
<col class="valuecol" span="3">
</colgroup>
<thead>
<tr>
<th scope="col">{% trans 'Statements' %}</th>
<th class="text-end" scope="col">{% trans %}#{% notes %}# = Amount of queries{% endtrans %}</th>
<th class="text-end" scope="col">{% trans 'ø per hour' %}</th>
<th class="text-end" scope="col">%</th>
</tr>
</thead>
<tbody>
{% for query in queries %}
<tr>
<th scope="row">{{ query.name }}</th>
<td class="font-monospace text-end">{{ format_number(query.value, 5, 0, true) }}</td>
<td class="font-monospace text-end">{{ format_number(query.per_hour, 4, 1, true) }}</td>
<td class="font-monospace text-end">{{ format_number(query.percentage, 0, 2) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="col">
<canvas id="query-statistics-chart" data-chart-data="{{ chart_data|json_encode|e('html_attr') }}" aria-label="{{ 'Pie chart with the most frequent statements.'|trans }}" role="img"></canvas>
</div>
</div>
{% endif %}
</ul>
</div>
<div class="row">
<table id="serverStatusQueriesDetails" class="table table-striped table-hover sortable col-md-4 col-12 w-auto">
<colgroup>
<col class="namecol">
<col class="valuecol" span="3">
</colgroup>
<thead>
<tr>
<th scope="col">{% trans 'Statements' %}</th>
<th class="text-end" scope="col">{% trans %}#{% notes %}# = Amount of queries{% endtrans %}</th>
<th class="text-end" scope="col">{% trans 'ø per hour' %}</th>
<th class="text-end" scope="col">%</th>
</tr>
</thead>
<tbody>
{% for query in queries %}
<tr>
<th scope="row">{{ query.name }}</th>
<td class="font-monospace text-end">{{ format_number(query.value, 5, 0, true) }}</td>
<td class="font-monospace text-end">{{ format_number(query.per_hour, 4, 1, true) }}</td>
<td class="font-monospace text-end">{{ format_number(query.percentage, 0, 2) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="serverstatusquerieschart" class="w-100 col-12 col-md-6" data-chart="{{ chart|json_encode }}"></div>
</div>
{% else %}
{{ 'Not enough privilege to view query statistics.'|trans|error }}
{% endif %}
</div>
{% endblock %}

View File

@ -74,65 +74,28 @@ class QueriesControllerTest extends AbstractTestCase
$usedQueries = $this->data->usedQueries;
$totalQueries = array_sum($usedQueries);
$questionsFromStart = __('Questions since startup:')
. ' ' . Util::formatNumber($totalQueries, 0);
$questionsFromStart = __('Questions since startup:') . ' ' . Util::formatNumber($totalQueries, 0);
$this->assertStringContainsString('<h3 id="serverstatusqueries">', $html);
$this->assertStringContainsString('<h3>', $html);
$this->assertStringContainsString($questionsFromStart, $html);
$this->assertStringContainsString(
__('per hour:'),
$html,
);
$this->assertStringContainsString(
Util::formatNumber($totalQueries * $hourFactor, 0),
$html,
);
$this->assertStringContainsString('per hour:', $html);
$this->assertStringContainsString(Util::formatNumber($totalQueries * $hourFactor, 0), $html);
$valuePerMinute = Util::formatNumber($totalQueries * 60 / $this->data->status['Uptime'], 0);
$this->assertStringContainsString(
__('per minute:'),
$html,
);
$this->assertStringContainsString(
htmlspecialchars($valuePerMinute),
$html,
);
$this->assertStringContainsString('per minute:', $html);
$this->assertStringContainsString(htmlspecialchars($valuePerMinute), $html);
$this->assertStringContainsString(
__('Statements'),
$html,
);
$this->assertStringContainsString('Statements', $html);
$this->assertStringContainsString(
htmlspecialchars('change db'),
$html,
);
$this->assertStringContainsString('Change Db', $html);
$this->assertStringContainsString('54', $html);
$this->assertStringContainsString(
htmlspecialchars('select'),
$html,
);
$this->assertStringContainsString(
htmlspecialchars('set option'),
$html,
);
$this->assertStringContainsString(
htmlspecialchars('show databases'),
$html,
);
$this->assertStringContainsString(
htmlspecialchars('show status'),
$html,
);
$this->assertStringContainsString(
htmlspecialchars('show tables'),
$html,
);
$this->assertStringContainsString('Select', $html);
$this->assertStringContainsString('Set Option', $html);
$this->assertStringContainsString('Show Databases', $html);
$this->assertStringContainsString('Show Status', $html);
$this->assertStringContainsString('Show Tables', $html);
$this->assertStringContainsString(
'<div id="serverstatusquerieschart" class="w-100 col-12 col-md-6" data-chart="',
$html,
);
$this->assertStringContainsString('<canvas id="query-statistics-chart" data-chart-data="', $html);
}
}

View File

@ -68,7 +68,6 @@ module.exports = [
'server/status/monitor': rootPath + '/js/src/server/status/monitor.ts',
'server/status/processes': rootPath + '/js/src/server/status/processes.ts',
'server/status/queries': rootPath + '/js/src/server/status/queries.ts',
'server/status/sorter': rootPath + '/js/src/server/status/sorter.ts',
'server/status/variables': rootPath + '/js/src/server/status/variables.ts',
'server/user_groups': rootPath + '/js/src/server/user_groups.ts',
'server/variables': rootPath + '/js/src/server/variables.ts',
@ -173,6 +172,7 @@ module.exports = [
{ from: rootPath + '/node_modules/updated-jqplot/build/plugins/jqplot.canvasAxisLabelRenderer.js', to: publicPath + '/js/vendor/jqplot/plugins/jqplot.canvasAxisLabelRenderer.js' },
{ from: rootPath + '/node_modules/updated-jqplot/build/plugins/jqplot.cursor.js', to: publicPath + '/js/vendor/jqplot/plugins/jqplot.cursor.js' },
{ from: rootPath + '/node_modules/updated-jqplot/build/plugins/jqplot.highlighter.js', to: publicPath + '/js/vendor/jqplot/plugins/jqplot.highlighter.js' },
{ from: rootPath + '/node_modules/chart.js/dist/chart.umd.js', to: publicPath + '/js/vendor/chart.umd.js' },
],
}),
new WebpackConcatPlugin({

View File

@ -1310,6 +1310,11 @@
"@jridgewell/resolve-uri" "3.1.0"
"@jridgewell/sourcemap-codec" "1.4.14"
"@kurkle/color@^0.3.0":
version "0.3.2"
resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.2.tgz#5acd38242e8bde4f9986e7913c8fdf49d3aa199f"
integrity sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==
"@mapbox/jsonlint-lines-primitives@~2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234"
@ -1449,6 +1454,13 @@
dependencies:
"@popperjs/core" "^2.9.2"
"@types/chart.js@^2.9.37":
version "2.9.37"
resolved "https://registry.yarnpkg.com/@types/chart.js/-/chart.js-2.9.37.tgz#8af70862b154fedf938b5b87debdb3a70f6e3208"
integrity sha512-9bosRfHhkXxKYfrw94EmyDQcdjMaQPkU1fH2tDxu8DWXxf1mjzWQAV4laJF51ZbC2ycYwNDvIm1rGez8Bug0vg==
dependencies:
moment "^2.10.2"
"@types/codemirror@^5.60.7":
version "5.60.7"
resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-5.60.7.tgz#efbb78e5e79f90c6762c2127c02096648e600808"
@ -2250,6 +2262,13 @@ char-regex@^1.0.2:
resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf"
integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==
chart.js@^4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.2.1.tgz#d2bd5c98e9a0ae35408975b638f40513b067ba1d"
integrity sha512-6YbpQ0nt3NovAgOzbkSSeeAQu/3za1319dPUQTXn9WcOpywM8rGKxJHrhS8V8xEkAlk8YhEfjbuAPfUyp6jIsw==
dependencies:
"@kurkle/color" "^0.3.0"
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.3.0, chokidar@^3.4.0:
version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
@ -4175,6 +4194,11 @@ minimist@^1.2.6:
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
moment@^2.10.2:
version "2.29.4"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"