forked from LBRYCommunity/lbry-sdk
ver updates to new metrics
This commit is contained in:
parent
08da763327
commit
395518ff76
2 changed files with 193 additions and 92 deletions
|
@ -20,14 +20,11 @@ class ServerConnection {
|
||||||
channel = IOWebSocketChannel.connect(this.url);
|
channel = IOWebSocketChannel.connect(this.url);
|
||||||
int tick = 1;
|
int tick = 1;
|
||||||
channel.stream.listen((message) {
|
channel.stream.listen((message) {
|
||||||
Map data = json.decode(message);
|
var data = json.decode(message);
|
||||||
print(data);
|
print(data);
|
||||||
Map commands = data['commands'] ?? {};
|
|
||||||
_loadDataController.add(
|
_loadDataController.add(
|
||||||
ServerLoadDataPoint(
|
ServerLoadDataPoint.from_map(
|
||||||
tick,
|
tick, data
|
||||||
APICallMetrics.from_map(commands['search'] ?? {}),
|
|
||||||
APICallMetrics.from_map(commands['resolve'] ?? {})
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
tick++;
|
tick++;
|
||||||
|
@ -52,48 +49,106 @@ class ServerConnection {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TimeStats {
|
||||||
|
final int avg, min, max;
|
||||||
|
// percentiles
|
||||||
|
final int five;
|
||||||
|
final int twenty_five;
|
||||||
|
final int fifty;
|
||||||
|
final int seventy_five;
|
||||||
|
final int ninety_five;
|
||||||
|
TimeStats(
|
||||||
|
this.avg, this.min, this.five, this.twenty_five,
|
||||||
|
this.fifty, this.seventy_five, this.ninety_five,
|
||||||
|
this.max
|
||||||
|
);
|
||||||
|
TimeStats.from_list(List l): this(
|
||||||
|
l[0], l[1], l[2], l[3], l[4], l[5], l[6], l[7]
|
||||||
|
);
|
||||||
|
TimeStats.from_zeros(): this(
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0
|
||||||
|
);
|
||||||
|
factory TimeStats.from_list_or_zeros(List l) =>
|
||||||
|
l != null ? TimeStats.from_list(l): TimeStats.from_zeros();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class APICallMetrics {
|
class APICallMetrics {
|
||||||
final int started;
|
// total requests received
|
||||||
final int finished;
|
final int receive_count;
|
||||||
final int total_time;
|
// sum of these is total responses made
|
||||||
final int execution_time;
|
final int cache_response_count;
|
||||||
final int query_time;
|
final int query_response_count;
|
||||||
final int query_count;
|
final int intrp_response_count;
|
||||||
final int cache_hit;
|
final int error_response_count;
|
||||||
final int avg_wait_time;
|
// stacked values for chart
|
||||||
final int avg_total_time;
|
final int cache_response_stack;
|
||||||
final int avg_execution_time;
|
final int query_response_stack;
|
||||||
final int avg_query_time_per_search;
|
final int intrp_response_stack;
|
||||||
final int avg_query_time_per_query;
|
final int error_response_stack;
|
||||||
|
// millisecond timings for non-cache responses
|
||||||
|
final TimeStats response;
|
||||||
|
final TimeStats interrupt;
|
||||||
|
final TimeStats error;
|
||||||
|
// response, interrupt and error each also report the python, wait and sql stats:
|
||||||
|
final TimeStats python;
|
||||||
|
final TimeStats wait;
|
||||||
|
final TimeStats sql;
|
||||||
|
// extended timings for individual sql executions
|
||||||
|
final TimeStats individual_sql;
|
||||||
|
final int individual_sql_count;
|
||||||
|
// actual queries
|
||||||
|
final List<String> errored_queries;
|
||||||
|
final List<String> interrupted_queries;
|
||||||
APICallMetrics(
|
APICallMetrics(
|
||||||
this.started, this.finished, this.total_time, this.execution_time,
|
this.receive_count,
|
||||||
this.query_time, this.query_count, this.cache_hit):
|
this.cache_response_count, this.query_response_count,
|
||||||
avg_wait_time=finished > 0 ? ((total_time - (execution_time + query_time))/finished).round() : 0,
|
this.intrp_response_count, this.error_response_count,
|
||||||
avg_total_time=finished > 0 ? (total_time/finished).round() : 0,
|
this.response, this.interrupt, this.error,
|
||||||
avg_execution_time=finished > 0 ? (execution_time/finished).round() : 0,
|
this.python, this.wait, this.sql,
|
||||||
avg_query_time_per_search=finished > 0 ? (query_time/finished).round() : 0,
|
this.individual_sql, this.individual_sql_count,
|
||||||
avg_query_time_per_query=query_count > 0 ? (query_time/query_count).round() : 0;
|
this.errored_queries, this.interrupted_queries
|
||||||
|
):
|
||||||
|
cache_response_stack=cache_response_count+query_response_count+intrp_response_count+error_response_count,
|
||||||
|
query_response_stack=query_response_count+intrp_response_count+error_response_count,
|
||||||
|
intrp_response_stack=intrp_response_count+error_response_count,
|
||||||
|
error_response_stack=error_response_count;
|
||||||
APICallMetrics.from_map(Map data): this(
|
APICallMetrics.from_map(Map data): this(
|
||||||
data['started'] ?? 0,
|
data["receive_count"] ?? 0,
|
||||||
data['finished'] ?? 0,
|
data["cache_response_count"] ?? 0,
|
||||||
data['total_time'] ?? 0,
|
data["query_response_count"] ?? 0,
|
||||||
data['execution_time'] ?? 0,
|
data["intrp_response_count"] ?? 0,
|
||||||
data['query_time'] ?? 0,
|
data["error_response_count"] ?? 0,
|
||||||
data['query_count'] ?? 0,
|
TimeStats.from_list_or_zeros(data["response"]),
|
||||||
data['cache_hit'] ?? 0,
|
TimeStats.from_list_or_zeros(data["interrupt"]),
|
||||||
|
TimeStats.from_list_or_zeros(data["error"]),
|
||||||
|
TimeStats.from_list_or_zeros(data["python"]),
|
||||||
|
TimeStats.from_list_or_zeros(data["wait"]),
|
||||||
|
TimeStats.from_list_or_zeros(data["sql"]),
|
||||||
|
TimeStats.from_list_or_zeros(data["individual_sql"]),
|
||||||
|
data["individual_sql_count"] ?? 0,
|
||||||
|
List<String>.from(data["errored_queries"] ?? const []),
|
||||||
|
List<String>.from(data["interrupted_queries"] ?? const []),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ServerLoadDataPoint {
|
class ServerLoadDataPoint {
|
||||||
final int tick;
|
final int tick;
|
||||||
|
final int sessions;
|
||||||
final APICallMetrics search;
|
final APICallMetrics search;
|
||||||
final APICallMetrics resolve;
|
final APICallMetrics resolve;
|
||||||
ServerLoadDataPoint(this.tick, this.search, this.resolve);
|
const ServerLoadDataPoint(
|
||||||
ServerLoadDataPoint.empty():
|
this.tick, this.sessions, this.search, this.resolve
|
||||||
tick = 0,
|
);
|
||||||
search=APICallMetrics.from_map({}),
|
ServerLoadDataPoint.from_map(int tick, Map data): this(
|
||||||
resolve=APICallMetrics.from_map({});
|
tick, (data['status'] ?? const {})['sessions'] ?? 0,
|
||||||
|
APICallMetrics.from_map((data['api'] ?? const {})['search'] ?? const {}),
|
||||||
|
APICallMetrics.from_map((data['api'] ?? const {})['resolve'] ?? const {})
|
||||||
|
);
|
||||||
|
ServerLoadDataPoint.empty(): this(
|
||||||
|
0, 0, APICallMetrics.from_map(const {}), APICallMetrics.from_map(const {})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,12 @@ class ServerCharts extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var server = Provider.of<Server>(context, listen: false);
|
var server = Provider.of<Server>(context, listen: false);
|
||||||
return ListView(children: <Widget>[
|
return ListView(children: <Widget>[
|
||||||
SizedBox(height: 300.0, child: ServerLoadChart(server)),
|
SizedBox(height: 300.0, child: APILoadChart(
|
||||||
|
server, "Search", (ServerLoadDataPoint dataPoint) => dataPoint.search
|
||||||
|
)),
|
||||||
|
//SizedBox(height: 300.0, child: APILoadChart(
|
||||||
|
// server, "Resolve", (ServerLoadDataPoint dataPoint) => dataPoint.resolve
|
||||||
|
//)),
|
||||||
SizedBox(height: 300.0, child: ServerPerformanceChart(server)),
|
SizedBox(height: 300.0, child: ServerPerformanceChart(server)),
|
||||||
//SizedBox(height: 220.0, child: ClientLoadChart(server.clientLoadManager)),
|
//SizedBox(height: 220.0, child: ClientLoadChart(server.clientLoadManager)),
|
||||||
//SizedBox(height: 220.0, child: ClientPerformanceChart(server.clientLoadManager)),
|
//SizedBox(height: 220.0, child: ClientPerformanceChart(server.clientLoadManager)),
|
||||||
|
@ -22,16 +27,20 @@ class ServerCharts extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ServerLoadChart extends StatefulWidget {
|
typedef APICallMetrics APIGetter(ServerLoadDataPoint dataPoint);
|
||||||
|
|
||||||
|
class APILoadChart extends StatefulWidget {
|
||||||
final Server server;
|
final Server server;
|
||||||
ServerLoadChart(this.server);
|
final String name;
|
||||||
|
final APIGetter getter;
|
||||||
|
APILoadChart(this.server, this.name, this.getter);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<StatefulWidget> createState() => ServerLoadChartState();
|
State<StatefulWidget> createState() => APILoadChartState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class ServerLoadChartState extends State<ServerLoadChart> {
|
class APILoadChartState extends State<APILoadChart> {
|
||||||
|
|
||||||
List<charts.Series<ServerLoadDataPoint, int>> seriesData;
|
List<charts.Series<ServerLoadDataPoint, int>> seriesData;
|
||||||
|
|
||||||
|
@ -39,54 +48,73 @@ class ServerLoadChartState extends State<ServerLoadChart> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
seriesData = [
|
seriesData = [
|
||||||
|
/*
|
||||||
charts.Series<ServerLoadDataPoint, int>(
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
id: 'Search Cache',
|
id: 'Received',
|
||||||
|
colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault.lighter,
|
||||||
|
strokeWidthPxFn: (_, __) => 4.0,
|
||||||
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
|
measureFn: (ServerLoadDataPoint load, _) => widget.getter(load).receive_count,
|
||||||
|
data: widget.server.serverLoadData,
|
||||||
|
),*/
|
||||||
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
|
id: 'Cache',
|
||||||
|
colorFn: (_, __) =>
|
||||||
|
charts.MaterialPalette.green.shadeDefault,
|
||||||
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
|
measureFn: (ServerLoadDataPoint load, _) => widget.getter(load).cache_response_stack,
|
||||||
|
data: widget.server.serverLoadData,
|
||||||
|
),
|
||||||
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
|
id: 'Query',
|
||||||
|
colorFn: (_, __) =>
|
||||||
|
charts.MaterialPalette.blue.shadeDefault,
|
||||||
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
|
measureFn: (ServerLoadDataPoint load, _) => widget.getter(load).query_response_stack,
|
||||||
|
data: widget.server.serverLoadData,
|
||||||
|
),
|
||||||
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
|
id: 'Interrupts',
|
||||||
|
colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault.lighter,
|
||||||
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
|
measureFn: (ServerLoadDataPoint load, _) => widget.getter(load).intrp_response_stack,
|
||||||
|
data: widget.server.serverLoadData,
|
||||||
|
),
|
||||||
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
|
id: 'Errors',
|
||||||
|
colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault,
|
||||||
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
|
measureFn: (ServerLoadDataPoint load, _) => widget.getter(load).error_response_stack,
|
||||||
|
data: widget.server.serverLoadData,
|
||||||
|
),
|
||||||
|
/*
|
||||||
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
|
id: '${widget.name} Interrupted',
|
||||||
|
colorFn: (_, __) =>
|
||||||
|
charts.MaterialPalette.pink.shadeDefault.lighter,
|
||||||
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
|
measureFn: (ServerLoadDataPoint load, _) => widget.getter(load).interrupted,
|
||||||
|
strokeWidthPxFn: (ServerLoadDataPoint load, _) => 5.0,
|
||||||
|
data: widget.server.serverLoadData,
|
||||||
|
),
|
||||||
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
|
id: '${widget.name} Errored',
|
||||||
|
colorFn: (_, __) =>
|
||||||
|
charts.MaterialPalette.red.shadeDefault.darker,
|
||||||
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
|
measureFn: (ServerLoadDataPoint load, _) => widget.getter(load).errored,
|
||||||
|
strokeWidthPxFn: (ServerLoadDataPoint load, _) => 5.0,
|
||||||
|
data: widget.server.serverLoadData,
|
||||||
|
),
|
||||||
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
|
id: '${widget.name} From Cache',
|
||||||
colorFn: (_, __) => charts.MaterialPalette.green.shadeDefault.darker,
|
colorFn: (_, __) => charts.MaterialPalette.green.shadeDefault.darker,
|
||||||
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
measureFn: (ServerLoadDataPoint load, _) => load.search.cache_hit,
|
measureFn: (ServerLoadDataPoint load, _) => widget.getter(load).cache_hits,
|
||||||
data: widget.server.serverLoadData,
|
strokeWidthPxFn: (ServerLoadDataPoint load, _) => 3.0,
|
||||||
),
|
|
||||||
charts.Series<ServerLoadDataPoint, int>(
|
|
||||||
id: 'Search Finish',
|
|
||||||
colorFn: (_, __) =>
|
|
||||||
charts.MaterialPalette.deepOrange.shadeDefault.darker,
|
|
||||||
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
|
||||||
measureFn: (ServerLoadDataPoint load, _) => load.search.finished,
|
|
||||||
strokeWidthPxFn: (ServerLoadDataPoint load, _) => 5.0,
|
|
||||||
data: widget.server.serverLoadData,
|
|
||||||
),
|
|
||||||
charts.Series<ServerLoadDataPoint, int>(
|
|
||||||
id: 'Search Start',
|
|
||||||
colorFn: (_, __) =>
|
|
||||||
charts.MaterialPalette.deepOrange.shadeDefault.lighter,
|
|
||||||
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
|
||||||
measureFn: (ServerLoadDataPoint load, _) => load.search.started,
|
|
||||||
strokeWidthPxFn: (ServerLoadDataPoint load, _) => 1.0,
|
|
||||||
data: widget.server.serverLoadData,
|
|
||||||
),
|
|
||||||
charts.Series<ServerLoadDataPoint, int>(
|
|
||||||
id: 'Resolve Cache',
|
|
||||||
colorFn: (_, __) => charts.MaterialPalette.cyan.shadeDefault.darker,
|
|
||||||
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
|
||||||
measureFn: (ServerLoadDataPoint load, _) => load.resolve.cache_hit,
|
|
||||||
data: widget.server.serverLoadData,
|
|
||||||
),
|
|
||||||
charts.Series<ServerLoadDataPoint, int>(
|
|
||||||
id: 'Resolve Finish',
|
|
||||||
colorFn: (_, __) => charts.MaterialPalette.teal.shadeDefault.darker,
|
|
||||||
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
|
||||||
measureFn: (ServerLoadDataPoint load, _) => load.resolve.finished,
|
|
||||||
strokeWidthPxFn: (ServerLoadDataPoint load, _) => 5.0,
|
|
||||||
data: widget.server.serverLoadData,
|
|
||||||
),
|
|
||||||
charts.Series<ServerLoadDataPoint, int>(
|
|
||||||
id: 'Resolve Start',
|
|
||||||
colorFn: (_, __) => charts.MaterialPalette.teal.shadeDefault.lighter,
|
|
||||||
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
|
||||||
measureFn: (ServerLoadDataPoint load, _) => load.resolve.started,
|
|
||||||
strokeWidthPxFn: (ServerLoadDataPoint load, _) => 1.0,
|
|
||||||
data: widget.server.serverLoadData,
|
data: widget.server.serverLoadData,
|
||||||
),
|
),
|
||||||
|
*/
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,7 +122,9 @@ class ServerLoadChartState extends State<ServerLoadChart> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return StreamBuilder<ServerLoadDataPoint>(
|
return StreamBuilder<ServerLoadDataPoint>(
|
||||||
stream: widget.server.serverLoadStream,
|
stream: widget.server.serverLoadStream,
|
||||||
builder: (BuildContext context, _) => BetterLineChart(seriesData)
|
builder: (BuildContext context, _) => BetterLineChart(seriesData,
|
||||||
|
//renderer: new charts.LineRendererConfig<num>(includeArea: true)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -117,27 +147,41 @@ class ServerPerformanceChartState extends State<ServerPerformanceChart> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
seriesData = [
|
seriesData = [
|
||||||
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
|
id: 'Waiting 95 Percentile',
|
||||||
|
colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault.lighter,
|
||||||
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
|
measureFn: (ServerLoadDataPoint load, _) => load.search.wait.ninety_five,
|
||||||
|
data: widget.server.serverLoadData,
|
||||||
|
),
|
||||||
charts.Series<ServerLoadDataPoint, int>(
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
id: 'Avg. Waiting',
|
id: 'Avg. Waiting',
|
||||||
colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault.darker,
|
colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault.darker,
|
||||||
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
measureFn: (ServerLoadDataPoint load, _) => load.search.avg_wait_time,
|
measureFn: (ServerLoadDataPoint load, _) => load.search.wait.avg,
|
||||||
data: widget.server.serverLoadData,
|
data: widget.server.serverLoadData,
|
||||||
),
|
),
|
||||||
charts.Series<ServerLoadDataPoint, int>(
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
id: 'Avg. Executing',
|
id: 'Avg. Executing',
|
||||||
colorFn: (_, __) => charts.MaterialPalette.teal.shadeDefault.lighter,
|
colorFn: (_, __) => charts.MaterialPalette.teal.shadeDefault.darker,
|
||||||
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
measureFn: (ServerLoadDataPoint load, _) => load.search.avg_execution_time,
|
measureFn: (ServerLoadDataPoint load, _) => load.search.python.avg,
|
||||||
|
data: widget.server.serverLoadData,
|
||||||
|
),
|
||||||
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
|
id: 'SQLite 95 Percentile',
|
||||||
|
colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault.lighter,
|
||||||
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
|
measureFn: (ServerLoadDataPoint load, _) => load.search.sql.ninety_five,
|
||||||
data: widget.server.serverLoadData,
|
data: widget.server.serverLoadData,
|
||||||
),
|
),
|
||||||
charts.Series<ServerLoadDataPoint, int>(
|
charts.Series<ServerLoadDataPoint, int>(
|
||||||
id: 'Avg. SQLite',
|
id: 'Avg. SQLite',
|
||||||
colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault.darker,
|
colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault.darker,
|
||||||
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
domainFn: (ServerLoadDataPoint load, _) => load.tick,
|
||||||
measureFn: (ServerLoadDataPoint load, _) => load.search.avg_query_time_per_search,
|
measureFn: (ServerLoadDataPoint load, _) => load.search.sql.avg,
|
||||||
data: widget.server.serverLoadData,
|
data: widget.server.serverLoadData,
|
||||||
)
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -260,11 +304,13 @@ class BetterLineChart extends charts.LineChart {
|
||||||
final int itemCount;
|
final int itemCount;
|
||||||
final Object lastItem;
|
final Object lastItem;
|
||||||
|
|
||||||
BetterLineChart(List<charts.Series<dynamic, int>> seriesList):
|
BetterLineChart(List<charts.Series<dynamic, int>> seriesList, {charts.LineRendererConfig renderer}):
|
||||||
itemCount = seriesList[0].data.length,
|
itemCount = seriesList[0].data.length,
|
||||||
lastItem = seriesList[0].data.last,
|
lastItem = seriesList[0].data.last,
|
||||||
super(
|
super(
|
||||||
seriesList,
|
seriesList,
|
||||||
|
animate: false,
|
||||||
|
defaultRenderer: renderer,
|
||||||
behaviors: [charts.SeriesLegend()],
|
behaviors: [charts.SeriesLegend()],
|
||||||
domainAxis: charts.NumericAxisSpec(
|
domainAxis: charts.NumericAxisSpec(
|
||||||
viewport: new charts.NumericExtents(
|
viewport: new charts.NumericExtents(
|
||||||
|
|
Loading…
Reference in a new issue