log_sense 1.6.1 → 1.7.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.
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "optparse/date"
5
+ require "log_sense/version"
6
+ require "log_sense/options/checker"
7
+
8
+ module LogSense
9
+ #
10
+ # Parse command line options
11
+ #
12
+ module Options
13
+ module Parser
14
+ #
15
+ # parse command line options
16
+ #
17
+ def self.parse(options)
18
+ # Defaults
19
+ args = {
20
+ geolocation: true,
21
+ ignore_crawlers: false,
22
+ input_filenames: [],
23
+ input_format: "apache",
24
+ limit: 100,
25
+ no_selfpoll: false,
26
+ only_crawlers: false,
27
+ output_format: "html",
28
+ pattern: "php",
29
+ verbose: false,
30
+ }
31
+
32
+ opt_parser = OptionParser.new do |opts|
33
+ opts.banner = "Usage: log_sense [options] [logfile ...]"
34
+
35
+ opts.on(
36
+ "-tTITLE", "--title=TITLE",
37
+ String,
38
+ "Title to use in the report") do |optval|
39
+ args[:title] = optval
40
+ end
41
+
42
+ opts.on(
43
+ "-fFORMAT", "--input-format=FORMAT",
44
+ String,
45
+ "Log format (stored in log or sqlite3): rails or apache #{dft(args[:input_format])}") do |optval|
46
+ args[:input_format] = optval
47
+ end
48
+
49
+ opts.on(
50
+ "-iFORMAT", "--input-files=file,file,",
51
+ Array,
52
+ "Input file(s), log file or sqlite3 (can also be passed as arguments)") do |optval|
53
+ args[:input_filenames] = optval
54
+ end
55
+
56
+ opts.on(
57
+ "-tFORMAT", "--output-format=FORMAT",
58
+ String,
59
+ "Output format: html, txt, sqlite, ufw #{dft(args[:output_format])}") do |optval|
60
+ args[:output_format] = optval
61
+ end
62
+
63
+ opts.on(
64
+ "-oOUTPUT_FILE", "--output-file=OUTPUT_FILE",
65
+ String,
66
+ "Output file. #{dft('STDOUT')}") do |n|
67
+ args[:output_filename] = n
68
+ end
69
+
70
+ opts.on(
71
+ "-bDATE", "--begin=DATE",
72
+ Date,
73
+ "Consider only entries after or on DATE") do |optval|
74
+ args[:from_date] = optval
75
+ end
76
+
77
+ opts.on(
78
+ "-eDATE", "--end=DATE",
79
+ Date,
80
+ "Consider only entries before or on DATE") do |optval|
81
+ args[:to_date] = optval
82
+ end
83
+
84
+ opts.on(
85
+ "-lN", "--limit=N",
86
+ Integer,
87
+ "Limit to the N most requested resources #{dft(args[:limit])}") do |optval|
88
+ args[:limit] = optval
89
+ end
90
+
91
+ opts.on(
92
+ "-wWIDTH", "--width=WIDTH",
93
+ Integer,
94
+ "Maximum width of long columns in textual reports") do |optval|
95
+ args[:width] = optval
96
+ end
97
+
98
+ opts.on(
99
+ "-rROWS", "--rows=ROWS",
100
+ Integer,
101
+ "Maximum number of rows for columns with multiple entries in textual reports") do |optval|
102
+ args[:inner_rows] = optval
103
+ end
104
+
105
+ opts.on(
106
+ "-pPATTERN", "--pattern=PATTERN",
107
+ String,
108
+ "Pattern to use with ufw report to select IP to blacklist #{dft(args[:pattern])}") do |optval|
109
+ args[:pattern] = optval
110
+ end
111
+
112
+ opts.on("-cPOLICY", "--crawlers=POLICY",
113
+ String,
114
+ "Decide what to do with crawlers (applies to Apache Logs)") do |optval|
115
+ case optval
116
+ when "only"
117
+ args[:only_crawlers] = true
118
+ when "ignore"
119
+ args[:ignore_crawlers] = true
120
+ end
121
+ end
122
+
123
+ opts.on(
124
+ "--no-selfpoll",
125
+ "Ignore self poll entries (requests from ::1; applies to Apache Logs) #{dft(args[:no_selfpoll])}") do
126
+ args[:no_selfpoll] = true
127
+ end
128
+
129
+ opts.on("--no-geo",
130
+ "Do not geolocate entries #{dft(args[:geolocation])}") do
131
+ args[:geolocation] = false
132
+ end
133
+
134
+ opts.on(
135
+ "--verbose",
136
+ "Inform about progress (output to STDERR) #{dft(args[:verbose])}") do
137
+ args[:verbose] = true
138
+ end
139
+
140
+ opts.on("-v", "--version", "Prints version information") do
141
+ puts "log_sense version #{LogSense::VERSION}"
142
+ puts "Copyright (C) 2021-2024 Shair.Tech"
143
+ puts "Distributed under the terms of the MIT license"
144
+ exit
145
+ end
146
+
147
+ opts.on("-h", "--help", "Prints this help") do
148
+ puts opts
149
+ puts
150
+ puts "This is version #{LogSense::VERSION}"
151
+ puts
152
+ puts "Output formats:"
153
+ puts
154
+ puts Options::Checker.chains_to_s
155
+
156
+ exit 0
157
+ end
158
+ end
159
+
160
+ opt_parser.parse!(options)
161
+
162
+ args
163
+ end
164
+
165
+ def self.dft(value)
166
+ "(DEFAULT: #{value})"
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,409 @@
1
+ require "sqlite3"
2
+
3
+ module LogSense
4
+ module Rails
5
+ #
6
+ # parse a Rails log file and return a in-memory SQLite3 DB
7
+ #
8
+ class LogParser
9
+ #
10
+ # Tell users which format I can parse
11
+ #
12
+ def provide
13
+ [:rails]
14
+ end
15
+
16
+ def parse(streams, options = {})
17
+ db = SQLite3::Database.new ":memory:"
18
+
19
+ db.execute <<-EOS
20
+ CREATE TABLE IF NOT EXISTS Event(
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ exit_status TEXT,
23
+ started_at TEXT,
24
+ ended_at TEXT,
25
+ log_id TEXT,
26
+ ip TEXT,
27
+ unique_visitor TEXT,
28
+ url TEXT,
29
+ controller TEXT,
30
+ html_verb TEXT,
31
+ status INTEGER,
32
+ duration_total_ms FLOAT,
33
+ duration_views_ms FLOAT,
34
+ duration_ar_ms FLOAT,
35
+ allocations INTEGER,
36
+ comment TEXT,
37
+ source_file TEXT,
38
+ line_number INTEGER
39
+ )
40
+ EOS
41
+
42
+ ins = db.prepare <<-EOS
43
+ insert into Event(
44
+ exit_status,
45
+ started_at,
46
+ ended_at,
47
+ log_id,
48
+ ip,
49
+ unique_visitor,
50
+ url,
51
+ controller,
52
+ html_verb,
53
+ status,
54
+ duration_total_ms,
55
+ duration_views_ms,
56
+ duration_ar_ms,
57
+ allocations,
58
+ comment,
59
+ source_file,
60
+ line_number
61
+ )
62
+ values (#{Array.new(17, "?").join(", ")})
63
+ EOS
64
+
65
+ db.execute <<-EOS
66
+ CREATE TABLE IF NOT EXISTS Error(
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ log_id TEXT,
69
+ context TEXT,
70
+ description TEXT,
71
+ filename TEXT,
72
+ line_number INTEGER
73
+ )
74
+ EOS
75
+
76
+ ins_error = db.prepare <<-EOS
77
+ insert into Error(
78
+ log_id,
79
+ context,
80
+ description,
81
+ filename,
82
+ line_number
83
+ )
84
+ values (?, ?, ?, ?, ?)
85
+ EOS
86
+
87
+ db.execute <<-EOS
88
+ CREATE TABLE IF NOT EXISTS Render(
89
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
90
+ partial TEXT,
91
+ duration_ms FLOAT,
92
+ allocations INTEGER,
93
+ filename TEXT,
94
+ line_number INTEGER
95
+ )
96
+ EOS
97
+
98
+ ins_rendered = db.prepare <<-EOS
99
+ insert into Render(
100
+ partial,
101
+ duration_ms,
102
+ allocations,
103
+ filename,
104
+ line_number
105
+ )
106
+ values (?, ?, ?, ?, ?)
107
+ EOS
108
+
109
+ # requests in the log might be interleaved.
110
+ #
111
+ # We use the 'pending' variable to progressively store data
112
+ # about requests till they are completed; whey they are
113
+ # complete, we enter the entry in the DB and remove it from the
114
+ # hash
115
+ pending = {}
116
+
117
+ # Log lines are either one of:
118
+ #
119
+ # LOG_LEVEL, [ZULU_TIMESTAMP #NUMBER] INFO --: [ID] Started VERB "URL" for IP at TIMESTAMP
120
+ # LOG_LEVEL, [ZULU_TIMESTAMP #NUMBER] INFO --: [ID] Processing by CONTROLLER as FORMAT
121
+ # LOG_LEVEL, [ZULU_TIMESTAMP #NUMBER] INFO --: [ID] Parameters: JSON
122
+ # LOG_LEVEL, [ZULU_TIMESTAMP #NUMBER] INFO --: [ID] Rendered VIEW within LAYOUT (Duration: DURATION | Allocations: ALLOCATIONS)
123
+ # LOG_LEVEL, [ZULU_TIMESTAMP #NUMBER] INFO --: [ID] Completed STATUS STATUS_STRING in DURATION (Views: DURATION | ActiveRecord: DURATION | Allocations: NUMBER)
124
+ #
125
+ # and they appears in the order shown above: started, processing, ...
126
+ #
127
+ # Different requests might be interleaved, of course
128
+ #
129
+ streams.each do |stream|
130
+ stream.readlines.each_with_index do |line, line_number|
131
+ filename = stream == $stdin ? "stdin" : stream.path
132
+
133
+ #
134
+ # These are for development logs
135
+ #
136
+
137
+ data = match_and_process_rendered line
138
+ if data
139
+ ins_rendered.execute(
140
+ data[:partial], data[:duration], data[:allocations],
141
+ filename, line_number
142
+ )
143
+ end
144
+
145
+ #
146
+ #
147
+ #
148
+
149
+ # I and F for completed requests, [ is for error messages
150
+ next if line[0] != 'I' and line[0] != 'F' and line[0] != '['
151
+
152
+ data = match_and_process_error line
153
+ if data
154
+ ins_error.execute(data[:log_id],
155
+ data[:context],
156
+ data[:description],
157
+ filename,
158
+ line_number)
159
+ next
160
+ end
161
+
162
+ data = match_and_process_start line
163
+ if data
164
+ id = data[:log_id]
165
+ pending[id] = data.merge(pending[id] || {})
166
+ next
167
+ end
168
+
169
+ data = match_and_process_processing_by line
170
+ if data
171
+ id = data[:log_id]
172
+ pending[id] = data.merge(pending[id] || {})
173
+ next
174
+ end
175
+
176
+ data = match_and_process_fatal line
177
+ if data
178
+ id = data[:log_id]
179
+ # it might as well be that the first event started before
180
+ # the log. With this, we make sure we add only events whose
181
+ # start was logged and parsed
182
+ if pending[id]
183
+ event = data.merge(pending[id] || {})
184
+
185
+ ins.execute(
186
+ event[:exit_status],
187
+ event[:started_at],
188
+ event[:ended_at],
189
+ event[:log_id],
190
+ event[:ip],
191
+ unique_visitor_id(event),
192
+ event[:url],
193
+ event[:controller],
194
+ event[:html_verb],
195
+ event[:status],
196
+ event[:duration_total_ms],
197
+ event[:duration_views_ms],
198
+ event[:duration_ar_ms],
199
+ event[:allocations],
200
+ event[:comment],
201
+ filename,
202
+ line_number
203
+ )
204
+
205
+ pending.delete(id)
206
+ end
207
+ end
208
+
209
+ data = self.match_and_process_completed line
210
+ if data
211
+ id = data[:log_id]
212
+
213
+ # it might as well be that the first event started before
214
+ # the log. With this, we make sure we add only events whose
215
+ # start was logged and parsed
216
+ if pending[id]
217
+ event = data.merge (pending[id] || {})
218
+
219
+ ins.execute(
220
+ event[:exit_status],
221
+ event[:started_at],
222
+ event[:ended_at],
223
+ event[:log_id],
224
+ event[:ip],
225
+ unique_visitor_id(event),
226
+ event[:url],
227
+ event[:controller],
228
+ event[:html_verb],
229
+ event[:status],
230
+ event[:duration_total_ms],
231
+ event[:duration_views_ms],
232
+ event[:duration_ar_ms],
233
+ event[:allocations],
234
+ event[:comment],
235
+ filename,
236
+ line_number
237
+ )
238
+
239
+ pending.delete(id)
240
+ end
241
+ end
242
+ end
243
+ end
244
+
245
+ db
246
+ end
247
+
248
+ TIMESTAMP = /(?<timestamp>[^ ]+)/
249
+ ID = /(?<id>[a-z0-9-]+)/
250
+ VERB = /(?<verb>GET|POST|PATCH|PUT|DELETE)/
251
+ URL = /(?<url>[^"]+)/
252
+ IP = /(?<ip>[0-9.]+)/
253
+ STATUS = /(?<status>[0-9]+)/
254
+ STATUS_IN_WORDS = /(OK|Unauthorized|Found|Internal Server Error|Bad Request|Method Not Allowed|Request Timeout|Not Implemented|Bad Gateway|Service Unavailable)/
255
+ MSECS = /[0-9.]+/
256
+
257
+ # Error Messages
258
+ # [584cffcc-f1fd-4b5c-bb8b-b89621bd4921] ActionController::RoutingError (No route matches [GET] "/assets/foundation-icons.svg"):
259
+ # [fd8df8b5-83c9-48b5-a056-e5026e31bd5e] ActionView::Template::Error (undefined method `all_my_ancestor' for nil:NilClass):
260
+ # [d17ed55c-f5f1-442a-a9d6-3035ab91adf0] ActionView::Template::Error (undefined method `volunteer_for' for #<DonationsController:0x007f4864c564b8>
261
+ EXCEPTION = /[A-Za-z_0-9:]+(Error)?/
262
+ ERROR_REGEXP = /^\[#{ID}\] (?<context>#{EXCEPTION}) \((?<description>(#{EXCEPTION})?.*)\):/
263
+
264
+ def match_and_process_error line
265
+ matchdata = ERROR_REGEXP.match line
266
+ if matchdata
267
+ {
268
+ log_id: matchdata[:id],
269
+ context: matchdata[:context],
270
+ description: matchdata[:description]
271
+ }
272
+ end
273
+ end
274
+
275
+ # I, [2021-10-19T08:16:34.343858 #10477] INFO -- : [67103c0d-455d-4fe8-951e-87e97628cb66] Started GET "/grow/people/471" for 217.77.80.35 at 2021-10-19 08:16:34 +0000
276
+ STARTED_REGEXP = /I, \[#{TIMESTAMP} #[0-9]+\] INFO -- : \[#{ID}\] Started #{VERB} "#{URL}" for #{IP} at/
277
+
278
+ def match_and_process_start line
279
+ matchdata = STARTED_REGEXP.match line
280
+ if matchdata
281
+ {
282
+ started_at: matchdata[:timestamp],
283
+ log_id: matchdata[:id],
284
+ html_verb: matchdata[:verb],
285
+ url: matchdata[:url],
286
+ ip: matchdata[:ip]
287
+ }
288
+ end
289
+ end
290
+
291
+ # TODO: Add regexps for the performance data (Views ...). We have three cases (view, active records, allocations), (views, active records), (active records, allocations)
292
+ # I, [2021-10-19T08:16:34.712331 #10477] INFO -- : [67103c0d-455d-4fe8-951e-87e97628cb66] Completed 200 OK in 367ms (Views: 216.7ms | ActiveRecord: 141.3ms | Allocations: 168792)
293
+ # I, [2021-12-09T16:53:52.657727 #2735058] INFO -- : [0064e403-9eb2-439d-8fe1-a334c86f5532] Completed 200 OK in 13ms (Views: 11.1ms | ActiveRecord: 1.2ms)
294
+ # I, [2021-12-06T14:28:19.736545 #2804090] INFO -- : [34091cb5-3e7b-4042-aaf8-6c6510d3f14c] Completed 500 Internal Server Error in 66ms (ActiveRecord: 8.0ms | Allocations: 24885)
295
+ COMPLETED_REGEXP = /I, \[#{TIMESTAMP} #[0-9]+\] INFO -- : \[#{ID}\] Completed #{STATUS} #{STATUS_IN_WORDS} in (?<total>#{MSECS})ms \((Views: (?<views>#{MSECS})ms \| )?ActiveRecord: (?<arec>#{MSECS})ms( \| Allocations: (?<alloc>[0-9]+))?\)/
296
+
297
+ def match_and_process_completed(line)
298
+ matchdata = (COMPLETED_REGEXP.match line)
299
+ # exit_status = matchdata[:status].to_i == 500 ? "E" : "I"
300
+ if matchdata
301
+ {
302
+ exit_status: "I",
303
+ ended_at: matchdata[:timestamp],
304
+ log_id: matchdata[:id],
305
+ status: matchdata[:status],
306
+ duration_total_ms: matchdata[:total],
307
+ duration_views_ms: matchdata[:views],
308
+ duration_ar_ms: matchdata[:arec],
309
+ allocations: matchdata[:alloc],
310
+ comment: ""
311
+ }
312
+ end
313
+ end
314
+
315
+ # I, [2021-10-19T08:16:34.345162 #10477] INFO -- : [67103c0d-455d-4fe8-951e-87e97628cb66] Processing by PeopleController#show as HTML
316
+ PROCESSING_REGEXP = /I, \[#{TIMESTAMP} #[0-9]+\] INFO -- : \[#{ID}\] Processing by (?<controller>[^ ]+) as/
317
+
318
+ def match_and_process_processing_by line
319
+ matchdata = PROCESSING_REGEXP.match line
320
+ if matchdata
321
+ {
322
+ log_id: matchdata[:id],
323
+ controller: matchdata[:controller]
324
+ }
325
+ end
326
+ end
327
+
328
+ # F, [2021-12-04T00:34:05.838973 #2735058] FATAL -- : [3a16162e-a6a5-435e-a9d8-c4df5dc0f728]
329
+ # F, [2021-12-04T00:34:05.839157 #2735058] FATAL -- : [3a16162e-a6a5-435e-a9d8-c4df5dc0f728] ActionController::RoutingError (No route matches [GET] "/wp/wp-includes/wlwmanifest.xml"):
330
+ # F, [2021-12-04T00:34:05.839209 #2735058] FATAL -- : [3a16162e-a6a5-435e-a9d8-c4df5dc0f728]
331
+ # F, [2021-12-04T00:34:05.839269 #2735058] FATAL -- : [3a16162e-a6a5-435e-a9d8-c4df5dc0f728] actionpack (5.2.4.4) lib/action_dispatch/middleware/debug_exceptions.rb:65:in `call'
332
+ FATAL_REGEXP = /F, \[#{TIMESTAMP} #[0-9]+\] FATAL -- : \[#{ID}\] (?<comment>.*)$/
333
+
334
+ def match_and_process_fatal(line)
335
+ matchdata = FATAL_REGEXP.match line
336
+ if matchdata
337
+ {
338
+ exit_status: "F",
339
+ log_id: matchdata[:id],
340
+ comment: matchdata[:comment]
341
+ }
342
+ end
343
+ end
344
+
345
+ # Started GET "/projects?locale=it" for 127.0.0.1 at 2024-06-06 23:23:31 +0200
346
+ # Processing by EmployeesController#index as HTML
347
+ # Parameters: {"locale"=>"it"}
348
+ # [...]
349
+ # Completed 200 OK in 135ms (Views: 128.0ms | ActiveRecord: 2.5ms | Allocations: 453450)
350
+ #
351
+ # Started GET "/serviceworker.js" for 127.0.0.1 at 2024-06-06 23:23:29 +0200
352
+ # ActionController::RoutingError (No route matches [GET] "/serviceworker.js"):
353
+ #
354
+ #
355
+ # Started POST "/projects?locale=it" for 127.0.0.1 at 2024-06-06 23:34:33 +0200
356
+ # Processing by ProjectsController#create as TURBO_STREAM
357
+ # Parameters: {"authenticity_token"=>"[FILTERED]", "project"=>{"name"=>"AA", "funding_agency"=>"", "total_cost"=>"0,00", "personnel_cost"=>"0,00", "percentage_funded"=>"0", "from_date"=>"2024-01-01", "to_date"=>"2025-12-31", "notes"=>""}, "commit"=>"Crea Progetto", "locale"=>"it"}
358
+ #
359
+ # Completed in 48801ms (ActiveRecord: 17.8ms | Allocations: 2274498)
360
+ # Completed 422 Unprocessable Entity in 16ms (Views: 5.1ms | ActiveRecord: 2.0ms | Allocations: 10093)
361
+ #
362
+ # Completed 500 Internal Server Error in 24ms (ActiveRecord: 1.4ms | Allocations: 4660)
363
+ # ActionView::Template::Error (Error: Undefined variable: "$white".
364
+ # on line 6:28 of app/assets/stylesheets/_animations.scss
365
+ # from line 16:9 of app/assets/stylesheets/application.scss
366
+ # >> from { background-color: $white; }
367
+
368
+ # ---------------------------^
369
+ # ):
370
+ # 9: = csrf_meta_tags
371
+ # 10: = csp_meta_tag
372
+ # 11:
373
+ # 12: = stylesheet_link_tag "application", "data-turbo-track": "reload"
374
+ # 13: = javascript_importmap_tags
375
+ # 14:
376
+ # 15: %body
377
+ #
378
+ # app/views/layouts/application.html.haml:12
379
+ # app/controllers/application_controller.rb:26:in `switch_locale'
380
+
381
+ # Rendered devise/sessions/_project_partial.html.erb (Duration: 78.4ms | Allocations: 88373)
382
+ # Rendered devise/sessions/new.html.haml within layouts/application (Duration: 100.0ms | Allocations: 104118)
383
+ # Rendered application/_favicon.html.erb (Duration: 2.6ms | Allocations: 4454)
384
+ # Rendered layouts/_manage_notice.html.erb (Duration: 0.3ms | Allocations: 193)
385
+ # Rendered layout layouts/application.html.erb (Duration: 263.4ms | Allocations: 367467)
386
+ # Rendered donations/_switcher.html.haml (Duration: 41.1ms | Allocations: 9550)
387
+ # Rendered donations/_status_header.html.haml (Duration: 1.4ms | Allocations: 3192)
388
+ # Rendered donations/_status_header.html.haml (Duration: 0.0ms | Allocations: 7)
389
+ RENDERED_REGEXP = /^ *Rendered (?<partial>[^ ]+) .*\(Duration: (?<duration>[0-9.]+)ms \| Allocations: (?<allocations>[0-9]+)\)$/
390
+
391
+ def match_and_process_rendered(line)
392
+ matchdata = RENDERED_REGEXP.match line
393
+ if matchdata
394
+ {
395
+ partial: matchdata[:partial],
396
+ duration: matchdata[:duration],
397
+ allocations: matchdata[:allocations]
398
+ }
399
+ end
400
+ end
401
+
402
+ # generate a unique visitor id from an event
403
+ def unique_visitor_id(event)
404
+ date = event[:started_at] || event[:ended_at] || "1970-01-01"
405
+ "#{DateTime.parse(date).strftime("%Y-%m-%d")} #{event[:ip]}"
406
+ end
407
+ end
408
+ end
409
+ end
@@ -172,7 +172,7 @@ module LogSense
172
172
  def countries_table(data)
173
173
  data&.map { |k, v|
174
174
  [
175
- k,
175
+ k || "-",
176
176
  v.map { |x| x[1] }.inject(&:+),
177
177
  v.map { |x| x[0] }.uniq.size,
178
178
  v.map { |x| x[0] }.join(WORDS_SEPARATOR)
@@ -1,7 +1,3 @@
1
- <link rel="preconnect" href="https://fonts.googleapis.com">
2
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
3
- <link href="https://fonts.googleapis.com/css2?family=Fira+Sans:wght@300;700&display=swap" rel="stylesheet">
4
-
5
1
  <% LogSense::Emitter::CDN_CSS.each do |link| %>
6
2
  <link rel="stylesheet" type="text/css" href="<%= link %>">
7
3
  <% end %>
@@ -1,10 +1,10 @@
1
1
  <ul class="stats-list">
2
2
  <li>
3
- <%= data[:first_day].strftime("%b %d, %Y") %>
3
+ <%= data[:first_day]&.strftime("%b %d, %Y") %>
4
4
  <span class="stats-list-label">From</span>
5
5
  </li>
6
6
  <li>
7
- <%= data[:last_day].strftime("%b %d, %Y") %>
7
+ <%= data[:last_day]&.strftime("%b %d, %Y") %>
8
8
  <span class="stats-list-label">To</span>
9
9
  </li>
10
10
  <li class="stats-list-positive">
@@ -1,7 +1,8 @@
1
1
  #offCanvas {
2
2
  background: #d30001 !important;
3
3
  }
4
- h2 {
5
- background: #d30001 !important;
4
+
5
+ h1, h2 {
6
+ color: #d30001 !important;
6
7
  }
7
8
 
@@ -1,7 +1,7 @@
1
1
  <script>
2
2
  /* this is used both by Vega and DataTable for <%= report[:title] %>*/
3
3
  data_<%= index %> = [
4
- <% report[:rows].each do |row| -%>
4
+ <% (report[:rows] || []).each do |row| -%>
5
5
  {
6
6
  <% report[:header].each_with_index do |h, i| -%>
7
7
  "<%= h %>": "<%= Emitter::process row[i] %>",
@@ -1,6 +1,12 @@
1
+ :root {
2
+ --font-family: Inter, Helvetica, Arial, sans-serif;
3
+
4
+ }
5
+
1
6
  body {
2
- font-family: 'Fira Sans', sans-serif;
3
- font-size: 12px;
7
+ font-family: var(--font-family);
8
+ /* font-size: 12px; */
9
+ font-size: 0.86rem;
4
10
  }
5
11
 
6
12
  #offCanvas {
@@ -34,29 +40,22 @@ nav h2 {
34
40
  }
35
41
 
36
42
  h1 {
37
- font-family: 'Fira Sans', sans-serif;
43
+ font-family: var(--font-family);
38
44
  font-size: 2rem;
39
45
  font-weight: bold;
46
+ color: #1C1C1C;
40
47
  }
41
48
 
42
49
  h2 {
43
- font-family: 'Fira Sans', sans-serif;
44
- font-size: 1.2rem;
50
+ font-family: var(--font-family);
51
+ font-size: 1.6rem;
45
52
  font-weight: bold;
46
-
47
- color: white;
48
- background: #1C1C1C;
49
-
50
- padding: 0.2rem 0.8rem 0.2rem 0.8rem;
51
- border-radius: 5px 5px 0px 0px;
52
- }
53
-
54
- th {
55
- padding: 0.2rem 1.2rem 0.2rem 0.2rem !important
53
+ color: #1C1C1C;
56
54
  }
57
55
 
58
- td {
59
- padding: 0.2rem 1rem 0.2rem 0.2rem !important;
56
+ th, td {
57
+ padding-top: 0.2rem !important;
58
+ padding-bottom: 0.2rem !important;
60
59
  }
61
60
 
62
61
  .dataTables_wrapper {
@@ -64,9 +63,12 @@ td {
64
63
  padding: 0.5rem;
65
64
  }
66
65
 
66
+ table.dataTable > tbody > tr:nth-child(2n) {
67
+ border-shadow: none !important;
68
+ }
69
+
67
70
  article {
68
- margin-top: 1rem;
69
- border-radius: 5px;
71
+ margin-top: 2rem;
70
72
  }
71
73
 
72
74