visage-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +9 -0
- data/README.md +138 -0
- data/Rakefile +33 -0
- data/VERSION +1 -0
- data/bin/visage +17 -0
- data/config.ru +7 -0
- data/features/json.feature +66 -0
- data/features/site.feature +9 -0
- data/features/step_definitions/form_steps.rb +6 -0
- data/features/step_definitions/json_steps.rb +79 -0
- data/features/step_definitions/result_steps.rb +19 -0
- data/features/step_definitions/site_steps.rb +4 -0
- data/features/step_definitions/visage_steps.rb +20 -0
- data/features/step_definitions/webrat_steps.rb +42 -0
- data/features/support/env.rb +36 -0
- data/features/visage.feature +11 -0
- data/lib/visage/collectd/json.rb +142 -0
- data/lib/visage/collectd/profile.rb +36 -0
- data/lib/visage/config/fallback-colors.yaml +82 -0
- data/lib/visage/config/init.rb +33 -0
- data/lib/visage/config/plugin-colors.yaml +63 -0
- data/lib/visage/config/profiles.yaml +35 -0
- data/lib/visage/config/profiles.yaml.sample +33 -0
- data/lib/visage/config.rb +51 -0
- data/lib/visage/patches.rb +18 -0
- data/lib/visage/public/favicon.gif +0 -0
- data/lib/visage/public/javascripts/application.js +4 -0
- data/lib/visage/public/javascripts/g.line.js +217 -0
- data/lib/visage/public/javascripts/g.raphael.js +7 -0
- data/lib/visage/public/javascripts/graph.js +510 -0
- data/lib/visage/public/javascripts/mootools-1.2.3-core.js +4036 -0
- data/lib/visage/public/javascripts/mootools-1.2.3.1-more.js +104 -0
- data/lib/visage/public/javascripts/raphael-min.js +7 -0
- data/lib/visage/public/javascripts/raphael.js +3215 -0
- data/lib/visage/public/stylesheets/screen.css +96 -0
- data/lib/visage/views/index.haml +48 -0
- data/lib/visage/views/layout.haml +22 -0
- data/lib/visage/views/single.haml +43 -0
- data/lib/visage-app.rb +81 -0
- metadata +142 -0
@@ -0,0 +1,510 @@
|
|
1
|
+
/*
|
2
|
+
* visageBase()
|
3
|
+
*
|
4
|
+
* Base class for fetching data and setting graph options.
|
5
|
+
* Should be used by other classes to build specialised graphing behaviour.
|
6
|
+
*
|
7
|
+
*/
|
8
|
+
var visageBase = new Class({
|
9
|
+
Implements: [Options, Events],
|
10
|
+
options: {
|
11
|
+
width: 900,
|
12
|
+
height: 220,
|
13
|
+
leftEdge: 100,
|
14
|
+
topEdge: 10,
|
15
|
+
gridWidth: 670,
|
16
|
+
gridHeight: 200,
|
17
|
+
columns: 60,
|
18
|
+
rows: 8,
|
19
|
+
gridBorderColour: '#ccc',
|
20
|
+
shade: false,
|
21
|
+
secureJSON: false,
|
22
|
+
httpMethod: 'get'
|
23
|
+
},
|
24
|
+
initialize: function(element, host, plugin, options) {
|
25
|
+
this.parentElement = element;
|
26
|
+
this.setOptions(options);
|
27
|
+
this.options.host = host;
|
28
|
+
this.options.plugin = plugin;
|
29
|
+
this.buildGraphHeader();
|
30
|
+
this.buildGraphContainer();
|
31
|
+
this.canvas = Raphael(this.graphContainer, this.options.width, this.options.height);
|
32
|
+
this.getData(); // calls graphData
|
33
|
+
},
|
34
|
+
dataURL: function() {
|
35
|
+
var url = ['data', this.options.host, this.options.plugin]
|
36
|
+
// if the data exists on another host (useful for embedding)
|
37
|
+
if ($defined(this.options.baseurl)) {
|
38
|
+
url.unshift(this.options.baseurl.replace(/\/$/, ''))
|
39
|
+
}
|
40
|
+
// for specific plugin instances
|
41
|
+
if ($chk(this.options.pluginInstance)) {
|
42
|
+
url.push(this.options.pluginInstance)
|
43
|
+
}
|
44
|
+
// if no url is specified
|
45
|
+
if (!url[0].match(/http\:\/\//)) {
|
46
|
+
url[0] = '/' + url[0]
|
47
|
+
}
|
48
|
+
return url.join('/')
|
49
|
+
},
|
50
|
+
getData: function() {
|
51
|
+
this.request = new Request.JSONP({
|
52
|
+
url: this.dataURL(),
|
53
|
+
data: this.requestData,
|
54
|
+
secure: this.options.secureJSON,
|
55
|
+
method: this.options.httpMethod,
|
56
|
+
onComplete: function(json) {
|
57
|
+
this.graphData(json);
|
58
|
+
}.bind(this),
|
59
|
+
onFailure: function(header, value) {
|
60
|
+
$(this.parentElement).set('html', header)
|
61
|
+
}.bind(this)
|
62
|
+
});
|
63
|
+
|
64
|
+
this.request.send();
|
65
|
+
},
|
66
|
+
buildGraphHeader: function() {
|
67
|
+
header = $chk(this.options.name) ? this.options.name : this.options.plugin
|
68
|
+
this.graphHeader = new Element('h3', {
|
69
|
+
'class': 'graph-title',
|
70
|
+
'html': header
|
71
|
+
});
|
72
|
+
$(this.parentElement).grab(this.graphHeader);
|
73
|
+
},
|
74
|
+
buildGraphContainer: function() {
|
75
|
+
$(this.parentElement).set('style', 'padding-top: 1em');
|
76
|
+
|
77
|
+
this.graphContainer = new Element('div', {
|
78
|
+
'class': 'graph container',
|
79
|
+
'styles': {
|
80
|
+
'margin-bottom': '24px'
|
81
|
+
}
|
82
|
+
});
|
83
|
+
$(this.parentElement).grab(this.graphContainer)
|
84
|
+
}
|
85
|
+
});
|
86
|
+
|
87
|
+
|
88
|
+
/*
|
89
|
+
* visageGraph()
|
90
|
+
*
|
91
|
+
* General purpose graph for rendering data from a single plugin
|
92
|
+
* with multiple plugin instances.
|
93
|
+
*
|
94
|
+
* Builds upon visageBase().
|
95
|
+
*
|
96
|
+
*/
|
97
|
+
var visageGraph = new Class({
|
98
|
+
Extends: visageBase,
|
99
|
+
Implements: Chain,
|
100
|
+
// assemble data to graph, then draw it
|
101
|
+
graphData: function(data) {
|
102
|
+
|
103
|
+
this.ys = []
|
104
|
+
this.colors = []
|
105
|
+
this.instances = []
|
106
|
+
this.metrics = []
|
107
|
+
|
108
|
+
var host = this.options.host
|
109
|
+
var plugin = this.options.plugin
|
110
|
+
|
111
|
+
$each(data[host][plugin], function(instance, iname) {
|
112
|
+
$each(instance, function(metric, mname) {
|
113
|
+
this.colors.push(metric.color)
|
114
|
+
if ( !$defined(this.x) ) {
|
115
|
+
this.x = this.buildXAxis(metric)
|
116
|
+
}
|
117
|
+
this.ys.push(metric.data)
|
118
|
+
this.instances.push(iname) // labels
|
119
|
+
this.metrics.push(mname) // labels
|
120
|
+
}, this);
|
121
|
+
}, this);
|
122
|
+
|
123
|
+
this.buildContainers();
|
124
|
+
this.drawGraph();
|
125
|
+
|
126
|
+
this.buildLabels();
|
127
|
+
this.addSelectionInterface();
|
128
|
+
this.addDebugInterface();
|
129
|
+
this.buildDateSelector();
|
130
|
+
|
131
|
+
/* disabling this for now for dramatic effect
|
132
|
+
this.buildEmbedder();
|
133
|
+
*/
|
134
|
+
},
|
135
|
+
buildXAxis: function(metric) {
|
136
|
+
var start = metric.start.toInt(),
|
137
|
+
finish = metric.finish.toInt(),
|
138
|
+
length = metric.data.length,
|
139
|
+
interval = (finish - start) / length,
|
140
|
+
counter = start,
|
141
|
+
x = []
|
142
|
+
|
143
|
+
while (counter < finish) {
|
144
|
+
x.push(counter)
|
145
|
+
counter += interval
|
146
|
+
}
|
147
|
+
return x
|
148
|
+
},
|
149
|
+
drawGraph: function() {
|
150
|
+
|
151
|
+
var colors = this.colors;
|
152
|
+
var left = this.options.leftEdge
|
153
|
+
var top = this.options.topEdge
|
154
|
+
var width = this.options.gridWidth
|
155
|
+
var height = this.options.gridHeight
|
156
|
+
var x = this.x // x axis
|
157
|
+
var ys = this.ys // y axes
|
158
|
+
var xstep = x.length / 20
|
159
|
+
var shade = this.options.shade
|
160
|
+
|
161
|
+
this.canvas.g.txtattr.font = "11px 'sans-serif'";
|
162
|
+
this.graph = this.canvas.g.linechart(left, top, width, height, x, ys, {
|
163
|
+
nostroke: false,
|
164
|
+
width: 1.5,
|
165
|
+
axis: "0 0 1 1",
|
166
|
+
colors: colors,
|
167
|
+
axisxstep: xstep,
|
168
|
+
shade: shade
|
169
|
+
});
|
170
|
+
|
171
|
+
this.formatAxes();
|
172
|
+
},
|
173
|
+
formatAxes: function() {
|
174
|
+
|
175
|
+
/* clean up graph labels */
|
176
|
+
this.graph.axis[0].text.items.getLast().hide()
|
177
|
+
$each(this.graph.axis[0].text.items, function (time) {
|
178
|
+
|
179
|
+
var unixTime = time.attr('text')
|
180
|
+
var d = new Date(time.attr('text') * 1000);
|
181
|
+
time.attr({'text': d.strftime("%H:%M")});
|
182
|
+
|
183
|
+
time.mouseover(function () {
|
184
|
+
this.attr({'text': d.strftime("%H:%M")});
|
185
|
+
});
|
186
|
+
|
187
|
+
/*
|
188
|
+
time.mouseout(function () {
|
189
|
+
this.attr({'text': d.strftime("%H:%M")});
|
190
|
+
});
|
191
|
+
*/
|
192
|
+
});
|
193
|
+
|
194
|
+
$each(this.graph.axis[1].text.items, function (value) {
|
195
|
+
// FIXME: no JS reference on train means awful rounding hacks!
|
196
|
+
// if you are reading this, it's a bug!
|
197
|
+
if (value.attr('text') > 1073741824) {
|
198
|
+
var label = value.attr('text') / 1073741824;
|
199
|
+
var unit = 'g'
|
200
|
+
} else if (value.attr('text') > 1048576) {
|
201
|
+
// and again :-(
|
202
|
+
var label = value.attr('text') / 1048576;
|
203
|
+
var unit = 'm'
|
204
|
+
} else if (value.attr('text') > 1024) {
|
205
|
+
var label = value.attr('text') / 1024;
|
206
|
+
var unit = 'k';
|
207
|
+
} else {
|
208
|
+
var label = value.attr('text');
|
209
|
+
var unit = ''
|
210
|
+
}
|
211
|
+
|
212
|
+
var decimal = label.toString().split('.')
|
213
|
+
if ($chk(this.previous) && this.previous.toString()[0] == label.toString()[0] && decimal.length > 1) {
|
214
|
+
var round = '.' + decimal[1][0]
|
215
|
+
} else {
|
216
|
+
var round = ''
|
217
|
+
}
|
218
|
+
|
219
|
+
value.attr({'text': Math.floor(label) + round + unit})
|
220
|
+
this.previous = value.attr('text')
|
221
|
+
});
|
222
|
+
|
223
|
+
},
|
224
|
+
buildEmbedder: function() {
|
225
|
+
var pre = new Element('textarea', {
|
226
|
+
'id': 'embedder',
|
227
|
+
'class': 'embedder',
|
228
|
+
'html': this.embedCode(),
|
229
|
+
'styles': {
|
230
|
+
'width': '500px',
|
231
|
+
'padding': '3px'
|
232
|
+
}
|
233
|
+
});
|
234
|
+
this.embedderContainer.grab(pre);
|
235
|
+
|
236
|
+
var slider = new Fx.Slide(pre, {
|
237
|
+
duration: 200
|
238
|
+
});
|
239
|
+
|
240
|
+
slider.hide();
|
241
|
+
|
242
|
+
var toggler = new Element('a', {
|
243
|
+
'id': 'toggler',
|
244
|
+
'class': 'toggler',
|
245
|
+
'html': '(embed)',
|
246
|
+
'href': '#',
|
247
|
+
'styles': {
|
248
|
+
'font-size': '0.7em',
|
249
|
+
}
|
250
|
+
});
|
251
|
+
toggler.addEvent('click', function(e) {
|
252
|
+
e.stop();
|
253
|
+
slider.toggle();
|
254
|
+
});
|
255
|
+
this.embedderTogglerContainer.grab(toggler);
|
256
|
+
},
|
257
|
+
embedCode: function() {
|
258
|
+
baseurl = "{protocol}//{host}".substitute({'host': window.location.host, 'protocol': window.location.protocol});
|
259
|
+
code = "<script src='{baseurl}/javascripts/visage.js' type='text/javascript'></script>".substitute({'baseurl': baseurl});
|
260
|
+
code += "<div id='graph'></div>"
|
261
|
+
code += "<script type='text/javascript'>window.addEvent('domready', function() { var graph = new visageGraph('graph', '{host}', '{plugin}', ".substitute({'host': this.options.host, 'plugin': this.options.plugin});
|
262
|
+
code += "{"
|
263
|
+
code += "width: 900, height: 220, gridWidth: 800, gridHeight: 200, baseurl: '{baseurl}'".substitute({'baseurl': baseurl});
|
264
|
+
code += "}); });</script>"
|
265
|
+
return code.replace('<', '<').replace('>', '>')
|
266
|
+
},
|
267
|
+
addDebugInterface: function() {
|
268
|
+
var graph = this.graph;
|
269
|
+
/*
|
270
|
+
graph.hoverColumn(function () {
|
271
|
+
console.log([this.axis])
|
272
|
+
});
|
273
|
+
*/
|
274
|
+
},
|
275
|
+
addSelectionInterface: function() {
|
276
|
+
var graph = this.graph;
|
277
|
+
var parentElement = this.parentElement
|
278
|
+
var gridHeight = this.options.gridHeight
|
279
|
+
graph.selectionMade = true
|
280
|
+
this.graph.clickColumn(function () {
|
281
|
+
if ($chk(graph.selectionMade) && graph.selectionMade) {
|
282
|
+
if ($defined(graph.selection)) {
|
283
|
+
graph.selection.remove();
|
284
|
+
}
|
285
|
+
graph.selectionMade = false
|
286
|
+
graph.selection = this.paper.rect(this.x, 0, 1, gridHeight);
|
287
|
+
graph.selection.toBack();
|
288
|
+
graph.selection.attr({fill: '#555', stroke: '#555', opacity: 0.4});
|
289
|
+
graph.selectionStart = this.axis.toInt()
|
290
|
+
} else {
|
291
|
+
graph.selectionMade = true
|
292
|
+
graph.selectionFinish = this.axis.toInt()
|
293
|
+
var select = $(parentElement).getElement('div.timescale.container select')
|
294
|
+
var hasSelected = select.getChildren('option').some(function(option) {
|
295
|
+
return option.get('html') == 'selected'
|
296
|
+
});
|
297
|
+
if (!hasSelected) {
|
298
|
+
var option = new Element('option', {
|
299
|
+
html: 'selected',
|
300
|
+
value: '',
|
301
|
+
selected: true
|
302
|
+
});
|
303
|
+
select.grab(option)
|
304
|
+
}
|
305
|
+
}
|
306
|
+
});
|
307
|
+
this.graph.hoverColumn(function () {
|
308
|
+
if ($chk(graph.selection) && !graph.selectionMade) {
|
309
|
+
var width = this.x - graph.selection.attr('x');
|
310
|
+
graph.selection.attr({'width': width});
|
311
|
+
}
|
312
|
+
});
|
313
|
+
|
314
|
+
},
|
315
|
+
buildContainers: function() {
|
316
|
+
this.embedderTogglerContainer = new Element('div', {
|
317
|
+
'class': 'embedder-toggler container',
|
318
|
+
'styles': {
|
319
|
+
'float': 'right',
|
320
|
+
'width': '20%',
|
321
|
+
'text-align': 'right',
|
322
|
+
'margin-right': '12px',
|
323
|
+
'padding-top': '4px'
|
324
|
+
}
|
325
|
+
});
|
326
|
+
$(this.parentElement).grab(this.embedderTogglerContainer, 'top')
|
327
|
+
|
328
|
+
this.timescaleContainer = new Element('div', {
|
329
|
+
'class': 'timescale container',
|
330
|
+
'styles': {
|
331
|
+
'float': 'right',
|
332
|
+
'width': '20%'
|
333
|
+
}
|
334
|
+
});
|
335
|
+
$(this.parentElement).grab(this.timescaleContainer, 'top')
|
336
|
+
|
337
|
+
this.labelsContainer = new Element('div', {
|
338
|
+
'class': 'labels container',
|
339
|
+
'title': 'click to hide',
|
340
|
+
'styles': {
|
341
|
+
'float': 'left',
|
342
|
+
'margin-left': '80px',
|
343
|
+
'padding-bottom': '1em'
|
344
|
+
}
|
345
|
+
});
|
346
|
+
$(this.parentElement).grab(this.labelsContainer)
|
347
|
+
|
348
|
+
this.embedderContainer = new Element('div', {
|
349
|
+
'class': 'embedder container',
|
350
|
+
'styles': {
|
351
|
+
'font-style': 'monospace',
|
352
|
+
'margin-left': '80px',
|
353
|
+
'font-size': '0.8em',
|
354
|
+
'clear': 'both'
|
355
|
+
}
|
356
|
+
});
|
357
|
+
$(this.parentElement).grab(this.embedderContainer)
|
358
|
+
},
|
359
|
+
buildDateSelector: function() {
|
360
|
+
/*
|
361
|
+
* container
|
362
|
+
* \
|
363
|
+
* - form
|
364
|
+
* \
|
365
|
+
* - select
|
366
|
+
* | \
|
367
|
+
* | - option
|
368
|
+
* | |
|
369
|
+
* | - option
|
370
|
+
* |
|
371
|
+
* - submit
|
372
|
+
*/
|
373
|
+
var currentDate = new Date;
|
374
|
+
var currentUnixTime = parseInt(currentDate.getTime() / 1000);
|
375
|
+
|
376
|
+
var container = $(this.timescaleContainer);
|
377
|
+
var form = new Element('form', {
|
378
|
+
'action': this.dataURL(),
|
379
|
+
'method': 'get',
|
380
|
+
'events': {
|
381
|
+
'submit': function(e, foo) {
|
382
|
+
e.stop();
|
383
|
+
|
384
|
+
/*
|
385
|
+
* Get the selected option, turn it into a hash for
|
386
|
+
* getData() to use.
|
387
|
+
*/
|
388
|
+
data = new Hash()
|
389
|
+
if (e.target.getElement('select').getSelected().get('html') == 'selected') {
|
390
|
+
data.set('start', this.graph.selectionStart);
|
391
|
+
data.set('finish', this.graph.selectionFinish);
|
392
|
+
} else {
|
393
|
+
e.target.getElement('select').getSelected().each(function(option) {
|
394
|
+
split = option.value.split('=')
|
395
|
+
data.set(split[0], split[1])
|
396
|
+
currentTimePeriod = option.get('html') // is this setting a global?
|
397
|
+
}, this);
|
398
|
+
}
|
399
|
+
this.requestData = data
|
400
|
+
|
401
|
+
/* Nuke graph + labels. */
|
402
|
+
this.graph.remove();
|
403
|
+
delete this.x;
|
404
|
+
$(this.labelsContainer).empty();
|
405
|
+
$(this.timescaleContainer).empty();
|
406
|
+
$(this.embedderContainer).empty();
|
407
|
+
$(this.embedderTogglerContainer).empty();
|
408
|
+
if ($defined(this.graph.selection)) {
|
409
|
+
this.graph.selection.remove();
|
410
|
+
}
|
411
|
+
/* Draw everything again. */
|
412
|
+
this.getData();
|
413
|
+
}.bind(this)
|
414
|
+
}
|
415
|
+
});
|
416
|
+
|
417
|
+
var select = new Element('select', { 'class': 'date timescale' });
|
418
|
+
var timescales = new Hash({ 'hour': 1, '2 hours': 2, '6 hours': 6, '12 hours': 12,
|
419
|
+
'day': 24, '2 days': 48, '3 days': 72,
|
420
|
+
'week': 168, '2 weeks': 336, 'month': 672 });
|
421
|
+
timescales.each(function(hour, label) {
|
422
|
+
var current = this.currentTimePeriod == 'last {label}'.substitute({'label': label });
|
423
|
+
var value = "start={start}".substitute({'start': currentUnixTime - (hour * 3600)});
|
424
|
+
var html = 'last {label}'.substitute({'label': label });
|
425
|
+
|
426
|
+
var option = new Element('option', {
|
427
|
+
html: html,
|
428
|
+
value: value,
|
429
|
+
selected: (current ? 'selected' : '')
|
430
|
+
|
431
|
+
});
|
432
|
+
select.grab(option)
|
433
|
+
});
|
434
|
+
|
435
|
+
var submit = new Element('input', { 'type': 'submit', 'value': 'show' });
|
436
|
+
|
437
|
+
form.grab(select);
|
438
|
+
form.grab(submit);
|
439
|
+
container.grab(form);
|
440
|
+
},
|
441
|
+
buildLabels: function() {
|
442
|
+
//buildLabels: function(graphLines, instanceNames, dataSources, colors) {
|
443
|
+
|
444
|
+
this.ys.each(function(set, index) {
|
445
|
+
var path = this.graph.lines[index],
|
446
|
+
color = this.colors[index]
|
447
|
+
plugin = this.options.plugin
|
448
|
+
instance = this.instances[index]
|
449
|
+
metric = this.metrics[index]
|
450
|
+
|
451
|
+
var container = new Element('div', {
|
452
|
+
'class': 'label plugin',
|
453
|
+
'styles': {
|
454
|
+
'padding': '0.2em 0.5em 0',
|
455
|
+
'float': 'left',
|
456
|
+
'width': '180px',
|
457
|
+
'font-size': '0.8em'
|
458
|
+
},
|
459
|
+
'events': {
|
460
|
+
'mouseover': function(e) {
|
461
|
+
e.stop();
|
462
|
+
path.animate({'stroke-width': 3}, 300);
|
463
|
+
//path.toFront();
|
464
|
+
},
|
465
|
+
'mouseout': function(e) {
|
466
|
+
e.stop();
|
467
|
+
path.animate({'stroke-width': 1.5}, 300);
|
468
|
+
//path.toBack();
|
469
|
+
},
|
470
|
+
'click': function(e) {
|
471
|
+
e.stop();
|
472
|
+
path.attr('opacity') == 0 ? path.animate({'opacity': 1}, 350) : path.animate({'opacity': 0}, 350);
|
473
|
+
}
|
474
|
+
}
|
475
|
+
});
|
476
|
+
|
477
|
+
var box = new Element('div', {
|
478
|
+
'class': 'label plugin box ' + metric,
|
479
|
+
'html': ' ',
|
480
|
+
'styles': {
|
481
|
+
'background-color': color,
|
482
|
+
'width': '48px',
|
483
|
+
'height': '18px',
|
484
|
+
'float': 'left',
|
485
|
+
'margin-right': '0.5em'
|
486
|
+
}
|
487
|
+
});
|
488
|
+
|
489
|
+
// plugin/instance/metrics names can be unmeaningful. make them pretty
|
490
|
+
var name;
|
491
|
+
name = instance.replace(plugin, '');
|
492
|
+
name = name.replace('tcp_connections', '')
|
493
|
+
name = name.replace('ps_state', '')
|
494
|
+
name = name.replace(plugin.split('-')[0], '')
|
495
|
+
name += metric == "value" ? "" : " (" + metric + ")"
|
496
|
+
name = name.replace(/^[-|_]*/, '')
|
497
|
+
|
498
|
+
var desc = new Element('span', {
|
499
|
+
'class': 'label plugin description ' + metric,
|
500
|
+
'html': name
|
501
|
+
});
|
502
|
+
|
503
|
+
container.grab(box);
|
504
|
+
container.grab(desc);
|
505
|
+
$(this.labelsContainer).grab(container);
|
506
|
+
|
507
|
+
}, this);
|
508
|
+
}
|
509
|
+
})
|
510
|
+
|