litestack 0.2.6 → 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +1 -1
- data/assets/event_page.png +0 -0
- data/assets/index_page.png +0 -0
- data/assets/topic_page.png +0 -0
- data/lib/action_cable/subscription_adapter/litecable.rb +1 -11
- data/lib/generators/litestack/install/templates/database.yml +5 -1
- data/lib/litestack/liteboard/liteboard.rb +172 -35
- data/lib/litestack/liteboard/views/index.erb +52 -20
- data/lib/litestack/liteboard/views/layout.erb +189 -38
- data/lib/litestack/liteboard/views/litecable.erb +118 -0
- data/lib/litestack/liteboard/views/litecache.erb +144 -0
- data/lib/litestack/liteboard/views/litedb.erb +168 -0
- data/lib/litestack/liteboard/views/litejob.erb +151 -0
- data/lib/litestack/litecable.rb +24 -34
- data/lib/litestack/litecable.sql.yml +1 -1
- data/lib/litestack/litecache.rb +6 -17
- data/lib/litestack/litedb.rb +11 -2
- data/lib/litestack/litemetric.rb +43 -66
- data/lib/litestack/litemetric.sql.yml +14 -12
- data/lib/litestack/litemetric_collector.sql.yml +4 -4
- data/lib/litestack/litequeue.rb +9 -20
- data/lib/litestack/litesupport.rb +41 -20
- data/lib/litestack/version.rb +1 -1
- data/lib/litestack.rb +1 -1
- metadata +9 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a1125a2cb5a2d9ea277fb9a0774632eb297ed4d322882dd33776d8a8f1c6471e
|
4
|
+
data.tar.gz: 9cb917c999850b2bd668e8826a4be4f091ee4e8e0f963b54ed87f5b7a7b060d6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e51460d99e66732e3dcd72e9444f1d739df7ae37804eb9b308cdae0faa7c9c34d4e7554e8f9d0bf91905f7ae3e9b4d7470509a84653da2ee736582b268fe188a
|
7
|
+
data.tar.gz: 873b188bd6db924f1e56034c22f5cc465b01d653ac925a2d30de648271b0b64a705894667b90005bd3d457ad318d3ee4736b16a60fa128c34d894172bc9b8e16
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,14 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [0.3.0] - 2023-08-13
|
4
|
+
|
5
|
+
- Reworked the Litecable thread safety model
|
6
|
+
- Fixed multiple litejob bugs (thanks Stephen Margheim)
|
7
|
+
- Fixed Railtie dependency (thanks Marco Roth)
|
8
|
+
- Litesupport fixes (thanks Stephen Margheim)
|
9
|
+
- Much improved metrics reporting for Litedb, Litecache, Litejob & Litecable
|
10
|
+
- Removed (for now, will come again later) litemetric reporting support for ad-hoc modules
|
11
|
+
|
3
12
|
## [0.2.6] - 2023-07-16
|
4
13
|
|
5
14
|
- Much improved database location setting (thanks Brad Gessler)
|
data/README.md
CHANGED
@@ -183,7 +183,7 @@ production:
|
|
183
183
|
|
184
184
|
## Contributing
|
185
185
|
|
186
|
-
Bug reports are welcome on GitHub at https://github.com/oldmoe/litestack.
|
186
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/oldmoe/litestack.
|
187
187
|
|
188
188
|
## License
|
189
189
|
|
Binary file
|
Binary file
|
Binary file
|
@@ -10,20 +10,10 @@ module ActionCable
|
|
10
10
|
|
11
11
|
prepend ChannelPrefix
|
12
12
|
|
13
|
-
DEFAULT_OPTIONS = {
|
14
|
-
config_path: "./config/litecable.yml",
|
15
|
-
path: "./db/cable.db",
|
16
|
-
sync: 0, # no need to sync at all
|
17
|
-
mmap_size: 16 * 1024 * 1024, # 16MB of memory hold hot messages
|
18
|
-
expire_after: 10, # remove messages older than 10 seconds
|
19
|
-
listen_interval: 0.005, # check new messages every 5 milliseconds
|
20
|
-
metrics: false
|
21
|
-
}
|
22
|
-
|
23
13
|
def initialize(server, logger=nil)
|
24
14
|
@server = server
|
25
15
|
@logger = server.logger
|
26
|
-
super(
|
16
|
+
super({config_path: "./config/litecable.yml"})
|
27
17
|
end
|
28
18
|
|
29
19
|
def shutdown
|
@@ -6,10 +6,14 @@
|
|
6
6
|
#
|
7
7
|
# `Litesupport.root.join("data.sqlite3")` stores
|
8
8
|
# application data in the path `./db/#{Rails.env}/data.sqlite3`
|
9
|
+
#
|
10
|
+
# idle_timeout should be set to zero, to avoid recycling sqlite connections
|
11
|
+
# and losing the page cache
|
12
|
+
#
|
9
13
|
default: &default
|
10
14
|
adapter: litedb
|
11
15
|
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
12
|
-
|
16
|
+
idle_timeout: 0
|
13
17
|
database: <%= Litesupport.root.join("data.sqlite3") %>
|
14
18
|
|
15
19
|
development:
|
@@ -16,13 +16,21 @@ class Liteboard
|
|
16
16
|
get "/", to: ->(env) do
|
17
17
|
Liteboard.new(env).call(:index)
|
18
18
|
end
|
19
|
-
|
20
|
-
get "/topics
|
21
|
-
Liteboard.new(env).call(:
|
19
|
+
|
20
|
+
get "/topics/Litejob", to: ->(env) do
|
21
|
+
Liteboard.new(env).call(:litejob)
|
22
|
+
end
|
23
|
+
|
24
|
+
get "/topics/Litecache", to: ->(env) do
|
25
|
+
Liteboard.new(env).call(:litecache)
|
22
26
|
end
|
23
27
|
|
24
|
-
get "/topics
|
25
|
-
Liteboard.new(env).call(:
|
28
|
+
get "/topics/Litedb", to: ->(env) do
|
29
|
+
Liteboard.new(env).call(:litedb)
|
30
|
+
end
|
31
|
+
|
32
|
+
get "/topics/Litecable", to: ->(env) do
|
33
|
+
Liteboard.new(env).call(:litecable)
|
26
34
|
end
|
27
35
|
|
28
36
|
end
|
@@ -45,12 +53,6 @@ class Liteboard
|
|
45
53
|
after(res)
|
46
54
|
end
|
47
55
|
|
48
|
-
def render(tpl_name)
|
49
|
-
layout = Tilt.new("#{__dir__}/views/layout.erb")
|
50
|
-
tpl = Tilt.new("#{__dir__}/views/#{tpl_name.to_s}.erb")
|
51
|
-
res = layout.render(self){tpl.render(self)}
|
52
|
-
end
|
53
|
-
|
54
56
|
def after(body=nil)
|
55
57
|
[200, {'Cache-Control' => 'no-cache'}, [body]]
|
56
58
|
end
|
@@ -76,48 +78,150 @@ class Liteboard
|
|
76
78
|
end
|
77
79
|
@search = params(:search)
|
78
80
|
@search = nil if @search == ''
|
81
|
+
@topics = @lm.topic_summaries(@resolution, @step * @count, @order, @dir, @search)
|
79
82
|
end
|
80
83
|
|
81
84
|
def index
|
82
85
|
@order = 'topic' unless @order
|
83
|
-
topics = @lm.topics
|
84
|
-
@topics = @lm.topic_summaries(@resolution, @step * @count, @order, @dir, @search)
|
85
86
|
@topics.each do |topic|
|
86
87
|
data_points = @lm.topic_data_points(@step, @count, @resolution, topic[0])
|
87
|
-
topic << data_points.collect{|r| [r[0],r[2]]}
|
88
|
+
topic << data_points.collect{|r| [r[0],r[2] || 0]}
|
88
89
|
end
|
89
90
|
render :index
|
90
91
|
end
|
91
92
|
|
92
|
-
def
|
93
|
+
def litecache
|
93
94
|
@order = 'rcount' unless @order
|
94
|
-
@topic =
|
95
|
+
@topic = 'Litecache'
|
95
96
|
@events = @lm.events_summaries(@topic, @resolution, @order, @dir, @search, @step * @count)
|
96
97
|
@events.each do |event|
|
97
|
-
data_points = @lm.event_data_points(@step, @count, @resolution, @topic, event[
|
98
|
-
event
|
99
|
-
event
|
98
|
+
data_points = @lm.event_data_points(@step, @count, @resolution, @topic, event['name'])
|
99
|
+
event['counts'] = data_points.collect{|r| [r['rtime'],r['rcount']]}
|
100
|
+
event['values'] = data_points.collect{|r| [r['rtime'],r['ravg']]}
|
100
101
|
end
|
101
|
-
@snapshot =
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
102
|
+
@snapshot = read_snapshot(@topic)
|
103
|
+
@size = @snapshot[0][:summary][:size] rescue 0
|
104
|
+
@max_size = @snapshot[0][:summary][:max_size] rescue 0
|
105
|
+
@full = (@size / @max_size)*100 rescue 0
|
106
|
+
@entries = @snapshot[0][:summary][:entries] rescue 0
|
107
|
+
@gets = @events.find{|t| t['name'] == 'get'}
|
108
|
+
@sets = @events.find{|t| t['name'] == 'set'}
|
109
|
+
@reads = @gets['rcount'] rescue 0
|
110
|
+
@writes = @sets['rcount'] rescue 0
|
111
|
+
@hitrate = @gets['ravg'] rescue 0
|
112
|
+
@hits = @reads * @hitrate
|
113
|
+
@misses = @reads - @hits
|
114
|
+
@reads_vs_writes = @gets['counts'].collect.with_index{|obj, i| obj.clone << @sets['counts'][i][1] } rescue []
|
115
|
+
@hits_vs_misses = @gets['values'].collect.with_index{|obj, i| [obj[0], obj[1].to_f * @gets['counts'][i][1].to_f, (1 - obj[1].to_f) * @gets['counts'][i][1].to_f] } rescue []
|
116
|
+
@top_reads = @lm.keys_summaries(@topic, 'get', @resolution, @order, @dir, nil, @step * @count).first(8)
|
117
|
+
@top_writes = @lm.keys_summaries(@topic, 'set', @resolution, @order, @dir, nil, @step * @count).first(8)
|
118
|
+
render :litecache
|
119
|
+
end
|
120
|
+
|
121
|
+
def litedb
|
122
|
+
@order = 'rcount' unless @order
|
123
|
+
@topic = 'Litedb'
|
124
|
+
@events = @lm.events_summaries(@topic, @resolution, @order, @dir, @search, @step * @count)
|
125
|
+
@events.each do |event|
|
126
|
+
data_points = @lm.event_data_points(@step, @count, @resolution, @topic, event['name'])
|
127
|
+
event['counts'] = data_points.collect{|r| [r['rtime'],r['rcount'] || 0]}
|
128
|
+
event['values'] = data_points.collect{|r| [r['rtime'],r['rtotal'] || 0]}
|
106
129
|
end
|
107
|
-
|
130
|
+
@snapshot = read_snapshot(@topic)
|
131
|
+
@size = @snapshot[0][:summary][:size] rescue 0
|
132
|
+
@tables = @snapshot[0][:summary][:tables] rescue 0
|
133
|
+
@indexes = @snapshot[0][:summary][:indexes] rescue 0
|
134
|
+
@gets = @events.find{|t| t['name'] == 'Read'}
|
135
|
+
@sets = @events.find{|t| t['name'] == 'Write'}
|
136
|
+
@reads = @gets['rcount'] rescue 0
|
137
|
+
@writes = @sets['rcount'] rescue 0
|
138
|
+
@time = @gets['ravg'] rescue 0
|
139
|
+
@reads_vs_writes = @gets['counts'].collect.with_index{|obj, i| obj.clone << @sets['counts'][i][1] } rescue []
|
140
|
+
@reads_vs_writes_times = @gets['values'].collect.with_index{|obj, i| [obj[0], obj[1], @sets['values'][i][1].to_f] } rescue []
|
141
|
+
@read_times = @gets['rtotal'] rescue 0
|
142
|
+
@write_times = @sets['rtotal'] rescue 0
|
143
|
+
@slowest = @lm.keys_summaries(@topic, 'Read', @resolution, 'ravg', 'desc', nil, @step * @count).first(8)
|
144
|
+
@slowest += @lm.keys_summaries(@topic, 'Write', @resolution, 'ravg', 'desc', nil, @step * @count).first(8)
|
145
|
+
@slowest = @slowest.sort{|a, b| a['ravg'] <=> b['ravg']}.reverse.first(8)
|
146
|
+
@popular = @lm.keys_summaries(@topic, 'Read', @resolution, 'rtotal', 'desc', nil, @step * @count).first(8)
|
147
|
+
@popular += @lm.keys_summaries(@topic, 'Write', @resolution, 'rtotal', 'desc', nil, @step * @count).first(8)
|
148
|
+
@popular = @popular.sort{|a, b| a['rtotal'] <=> b['rtotal']}.reverse.first(8)
|
149
|
+
render :litedb
|
108
150
|
end
|
109
151
|
|
110
|
-
def
|
152
|
+
def litejob
|
111
153
|
@order = 'rcount' unless @order
|
112
|
-
@topic =
|
113
|
-
@
|
114
|
-
@
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
154
|
+
@topic = 'Litejob'
|
155
|
+
@events = @lm.events_summaries(@topic, @resolution, @order, @dir, @search, @step * @count)
|
156
|
+
@events.each do |event|
|
157
|
+
data_points = @lm.event_data_points(@step, @count, @resolution, @topic, event['name'])
|
158
|
+
event['counts'] = data_points.collect{|r| [r['rtime'],r['rcount'] || 0]}
|
159
|
+
event['values'] = data_points.collect{|r| [r['rtime'],r['rtotal'] || 0]}
|
160
|
+
end
|
161
|
+
@snapshot = read_snapshot(@topic)
|
162
|
+
@size = @snapshot[0][:summary][:size] rescue 0
|
163
|
+
@jobs = @snapshot[0][:summary][:jobs] rescue 0
|
164
|
+
@queues = @snapshot[0][:queues] rescue {}
|
165
|
+
@processed_jobs = @events.find{|e|e['name'] == 'perform'}
|
166
|
+
@processed_count = @processed_jobs['rcount'] rescue 0
|
167
|
+
@processing_time = @processed_jobs['rtotal'] rescue 0
|
168
|
+
keys_summaries = @lm.keys_summaries(@topic, 'perform', @resolution, 'rcount', 'desc', nil, @step * @count)
|
169
|
+
@processed_count_by_queue = keys_summaries.collect{|r|[r['key'], r['rcount']]}
|
170
|
+
@processing_time_by_queue = keys_summaries.collect{|r|[r['key'], r['rtotal']]} #.sort{|r1, r2| r1['rtotal'] > r2['rtotal'] }
|
171
|
+
@processed_count_over_time = @events.find{|e| e['name'] == 'perform'}['counts'] rescue []
|
172
|
+
@processing_time_over_time = @events.find{|e| e['name'] == 'perform'}['values'] rescue []
|
173
|
+
@processed_count_over_time_by_queues = []
|
174
|
+
@processing_time_over_time_by_queues = []
|
175
|
+
keys = ['Time']
|
176
|
+
keys_summaries.each_with_index do |summary,i|
|
177
|
+
key = summary['key']
|
178
|
+
keys << key
|
179
|
+
data_points = @lm.key_data_points(@step, @count, @resolution, @topic, 'perform', key)
|
180
|
+
if i == 0
|
181
|
+
data_points.each do |dp|
|
182
|
+
@processed_count_over_time_by_queues << [dp['rtime']]
|
183
|
+
@processing_time_over_time_by_queues << [dp['rtime']]
|
184
|
+
end
|
185
|
+
end
|
186
|
+
data_points.each_with_index do |dp, j|
|
187
|
+
@processed_count_over_time_by_queues[j] << (dp['rcount'] || 0)
|
188
|
+
@processing_time_over_time_by_queues[j] << (dp['rtotal'] || 0)
|
189
|
+
end
|
190
|
+
end
|
191
|
+
@processed_count_over_time_by_queues.unshift(keys)
|
192
|
+
@processing_time_over_time_by_queues.unshift(keys)
|
193
|
+
render :litejob
|
194
|
+
end
|
195
|
+
|
196
|
+
def litecable
|
197
|
+
@order = 'rcount' unless @order
|
198
|
+
@topic = 'Litecable'
|
199
|
+
@events = @lm.events_summaries(@topic, @resolution, @order, @dir, @search, @step * @count)
|
200
|
+
@events.each do |event|
|
201
|
+
data_points = @lm.event_data_points(@step, @count, @resolution, @topic, event['name'])
|
202
|
+
event['counts'] = data_points.collect{|r| [r['rtime'],r['rcount'] || 0]}
|
203
|
+
end
|
204
|
+
|
205
|
+
@subscription_count = @events.find{|t| t['name'] == 'subscribe'}['rcount'] rescue 0
|
206
|
+
@broadcast_count = @events.find{|t| t['name'] == 'broadcast'}['rcount'] rescue 0
|
207
|
+
@message_count = @events.find{|t| t['name'] == 'message'}['rcount'] rescue 0
|
208
|
+
|
209
|
+
@subscriptions_over_time = @events.find{|t| t['name'] == 'subscribe'}['counts'] rescue []
|
210
|
+
@broadcasts_over_time = @events.find{|t| t['name'] == 'broadcast'}['counts'] rescue []
|
211
|
+
@messages_over_time = @events.find{|t| t['name'] == 'message'}['counts'] rescue []
|
212
|
+
@messages_over_time = @messages_over_time.collect.with_index{|msg, i| [msg[0], @broadcasts_over_time[i][1], msg[1]]}
|
213
|
+
|
214
|
+
@top_subscribed_channels = @lm.keys_summaries(@topic, 'subscribe', @resolution, @order, @dir, @search, @step * @count).first(8)
|
215
|
+
@top_messaged_channels = @lm.keys_summaries(@topic, 'message', @resolution, @order, @dir, @search, @step * @count).first(8)
|
216
|
+
render :litecable
|
217
|
+
end
|
218
|
+
|
219
|
+
def index_url
|
220
|
+
"/?res=#{@res}&order=#{@order}&dir=#{@dir}&search=#{@search}"
|
221
|
+
end
|
222
|
+
|
223
|
+
def topic_url(topic)
|
224
|
+
"/topics/#{encode(topic)}?res=#{@res}&order=#{@order}&dir=#{@dir}&search=#{@search}"
|
121
225
|
end
|
122
226
|
|
123
227
|
def index_sort_url(field)
|
@@ -156,10 +260,43 @@ class Liteboard
|
|
156
260
|
URI.encode_uri_component(text)
|
157
261
|
end
|
158
262
|
|
263
|
+
def round(float)
|
264
|
+
return 0 unless float.is_a? Numeric
|
265
|
+
((float * 100).round).to_f / 100
|
266
|
+
end
|
267
|
+
|
268
|
+
def format(float)
|
269
|
+
string = float.to_s
|
270
|
+
whole, decimal = string.split('.')
|
271
|
+
whole = whole.split('').reverse.each_slice(3).map(&:join).join(',').reverse
|
272
|
+
whole = [whole, decimal].join('.') if decimal
|
273
|
+
whole
|
274
|
+
end
|
275
|
+
|
159
276
|
def self.app
|
160
277
|
@@app
|
161
278
|
end
|
162
279
|
|
280
|
+
private
|
281
|
+
|
282
|
+
def read_snapshot(topic)
|
283
|
+
snapshot = @lm.snapshot(topic)
|
284
|
+
if snapshot.empty?
|
285
|
+
snapshot = []
|
286
|
+
else
|
287
|
+
snapshot[0] = Oj.load(snapshot[0]) unless snapshot[0].nil?
|
288
|
+
end
|
289
|
+
snapshot
|
290
|
+
end
|
291
|
+
|
292
|
+
def render(tpl_name)
|
293
|
+
layout = Tilt.new("#{__dir__}/views/layout.erb")
|
294
|
+
tpl_path = "#{__dir__}/views/#{tpl_name.to_s}.erb"
|
295
|
+
tpl = Tilt.new(tpl_path)
|
296
|
+
res = layout.render(self){tpl.render(self)}
|
297
|
+
end
|
298
|
+
|
299
|
+
|
163
300
|
end
|
164
301
|
|
165
302
|
|
@@ -1,22 +1,54 @@
|
|
1
1
|
|
2
|
-
<h5>All topics</h5>
|
3
|
-
<div id="search"><form><input id="search-field" type="text" placeholder="Search topics" onkeydown="search_kd(this)" onkeyup="search_ku(this)" value="<%=@search%>"/></form></div>
|
4
|
-
<table class="sortable table">
|
5
|
-
<tr>
|
6
|
-
<th width="20%" class="<%='sorted' if @order == 'topic'%>"><a href="<%=index_sort_url('topic')%>">Topics</a> <%=dir('topic')%></th>
|
7
|
-
<th width="20%" class="<%='sorted' if @order == 'rcount'%>"><a href="<%=index_sort_url('rcount')%>">Total events</a> <%=dir('rcount')%></th>
|
8
|
-
<th width="40%">Events over time</th>
|
9
|
-
</tr>
|
10
|
-
<% @topics.each do |topic|%>
|
11
|
-
<tr>
|
12
|
-
<td title="<%=topic[0]%>"><div class="label"><a href="./topics/<%=encode(topic[0])%>?res=<%=@res%>"><%=topic[0]%></a></div></td>
|
13
|
-
<td><%=topic[3]%></td>
|
14
|
-
<td class="chart"><span class="inlinecolumn hidden" data-label="Count"><%=Oj.dump(topic[4]) if topic[4]%></span></td>
|
15
|
-
</tr>
|
16
|
-
<%end%>
|
17
|
-
<% if @topics.empty? %>
|
18
|
-
<tr>
|
19
|
-
<td class="empty" colspan="5">No data to display</td>
|
20
|
-
</tr>
|
21
|
-
<% end %>
|
22
2
|
|
3
|
+
|
4
|
+
|
5
|
+
<div class="container">
|
6
|
+
<% @topics.each do |topic|%>
|
7
|
+
|
8
|
+
<div class = "row justify-content-center">
|
9
|
+
|
10
|
+
<div class = "col-6">
|
11
|
+
<div class="card">
|
12
|
+
<div class="card-header">
|
13
|
+
<a href="./topics/<%=encode(topic[0])%>?res=<%=@res%>"><%=topic[0]%></a>
|
14
|
+
</div>
|
15
|
+
<div class="card-body">
|
16
|
+
<div class="container">
|
17
|
+
<div class= "row">
|
18
|
+
<div class= "col">
|
19
|
+
<h1><%=topic[3]%> <span class="fs-4">events</span></h1>
|
20
|
+
</div>
|
21
|
+
<div class= "col">
|
22
|
+
<span class="inlineminicolumn hidden" data-label="Count"><%=Oj.dump(topic[4].unshift(['Time', 'Count'])) if topic[4]%></span>
|
23
|
+
</div>
|
24
|
+
</div>
|
25
|
+
</div>
|
26
|
+
</div>
|
27
|
+
</div>
|
28
|
+
</div>
|
29
|
+
|
30
|
+
</div>
|
31
|
+
|
32
|
+
<div class = "row">
|
33
|
+
<br/>
|
34
|
+
</div>
|
35
|
+
|
36
|
+
|
37
|
+
<%end%>
|
38
|
+
<% if @topics.empty? %>
|
39
|
+
<div class = "row justify-content-center">
|
40
|
+
|
41
|
+
<div class = "col-6">
|
42
|
+
<div class="card">
|
43
|
+
<div class="card-header">
|
44
|
+
Topics
|
45
|
+
</div>
|
46
|
+
<div class="card-body justify-content-center">
|
47
|
+
No data to display
|
48
|
+
</div>
|
49
|
+
</div>
|
50
|
+
</div>
|
51
|
+
|
52
|
+
</div>
|
53
|
+
<%end%>
|
54
|
+
</div>
|