sentiero 1.0.0.alpha1
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 +7 -0
- data/LICENSE.txt +7 -0
- data/README.md +679 -0
- data/lib/sentiero/analytics/analyzer.rb +91 -0
- data/lib/sentiero/analytics/bounded.rb +29 -0
- data/lib/sentiero/analytics/browser_event_discovery.rb +70 -0
- data/lib/sentiero/analytics/collectors/click_collector.rb +135 -0
- data/lib/sentiero/analytics/collectors/custom_tag_collector.rb +61 -0
- data/lib/sentiero/analytics/collectors/error_collector.rb +89 -0
- data/lib/sentiero/analytics/collectors/form_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/frustration_collector.rb +85 -0
- data/lib/sentiero/analytics/collectors/scroll_collector.rb +156 -0
- data/lib/sentiero/analytics/collectors/vitals_collector.rb +104 -0
- data/lib/sentiero/analytics/conversion_analyzer.rb +247 -0
- data/lib/sentiero/analytics/engagement_analyzer.rb +331 -0
- data/lib/sentiero/analytics/entry_attribution.rb +71 -0
- data/lib/sentiero/analytics/error_discovery.rb +118 -0
- data/lib/sentiero/analytics/events.rb +21 -0
- data/lib/sentiero/analytics/exporter.rb +242 -0
- data/lib/sentiero/analytics/form_analyzer.rb +153 -0
- data/lib/sentiero/analytics/frustration/detectors.rb +158 -0
- data/lib/sentiero/analytics/frustration_analyzer.rb +235 -0
- data/lib/sentiero/analytics/funnel_analyzer.rb +160 -0
- data/lib/sentiero/analytics/heatmap_analyzer.rb +93 -0
- data/lib/sentiero/analytics/page_report_analyzer.rb +198 -0
- data/lib/sentiero/analytics/problem_detail.rb +97 -0
- data/lib/sentiero/analytics/scroll_depth_analyzer.rb +30 -0
- data/lib/sentiero/analytics/segmenter.rb +133 -0
- data/lib/sentiero/analytics/server_event_metrics.rb +120 -0
- data/lib/sentiero/analytics/stats.rb +30 -0
- data/lib/sentiero/analytics/stats_aggregator/result_builder.rb +153 -0
- data/lib/sentiero/analytics/stats_aggregator.rb +346 -0
- data/lib/sentiero/analytics/web_vitals_analyzer.rb +57 -0
- data/lib/sentiero/configuration.rb +184 -0
- data/lib/sentiero/erasure.rb +48 -0
- data/lib/sentiero/fingerprint.rb +34 -0
- data/lib/sentiero/ip_anonymizer.rb +29 -0
- data/lib/sentiero/redaction/config.rb +61 -0
- data/lib/sentiero/redaction.rb +207 -0
- data/lib/sentiero/reporter/configuration.rb +50 -0
- data/lib/sentiero/reporter/context.rb +31 -0
- data/lib/sentiero/reporter/dispatcher.rb +91 -0
- data/lib/sentiero/reporter/http_transport.rb +57 -0
- data/lib/sentiero/reporter/log_transport.rb +26 -0
- data/lib/sentiero/reporter/middleware.rb +62 -0
- data/lib/sentiero/reporter/normalizer.rb +14 -0
- data/lib/sentiero/reporter/null_transport.rb +18 -0
- data/lib/sentiero/reporter/report_context.rb +29 -0
- data/lib/sentiero/reporter/scrubber.rb +47 -0
- data/lib/sentiero/reporter/test_helper.rb +32 -0
- data/lib/sentiero/reporter/test_transport.rb +28 -0
- data/lib/sentiero/reporter.rb +214 -0
- data/lib/sentiero/roda.rb +47 -0
- data/lib/sentiero/store/error_store.rb +220 -0
- data/lib/sentiero/store/limits.rb +31 -0
- data/lib/sentiero/store/session_store.rb +118 -0
- data/lib/sentiero/store.rb +72 -0
- data/lib/sentiero/stores/file.rb +566 -0
- data/lib/sentiero/stores/memory.rb +362 -0
- data/lib/sentiero/stores/redis/keys.rb +59 -0
- data/lib/sentiero/stores/redis/lua.rb +119 -0
- data/lib/sentiero/stores/redis.rb +665 -0
- data/lib/sentiero/stores/sqlite/schema.rb +79 -0
- data/lib/sentiero/stores/sqlite.rb +626 -0
- data/lib/sentiero/user_agent.rb +32 -0
- data/lib/sentiero/version.rb +5 -0
- data/lib/sentiero/web/analytics_app.rb +538 -0
- data/lib/sentiero/web/assets/analytics-RH24EOLD.js +1 -0
- data/lib/sentiero/web/assets/dashboard-JFYNHZZV.js +3 -0
- data/lib/sentiero/web/assets/heatmap-EBKFWSKN.js +1 -0
- data/lib/sentiero/web/assets/import-HIMBJJ4S.js +1 -0
- data/lib/sentiero/web/assets/manifest.json +11 -0
- data/lib/sentiero/web/assets/recorder-SLLXSUUX.js +71 -0
- data/lib/sentiero/web/assets/rrweb-player-cd435a95.js +126 -0
- data/lib/sentiero/web/assets/rrweb-player-css-ce5e9629.css +2 -0
- data/lib/sentiero/web/assets/sessions_index-2RAGTEZM.js +1 -0
- data/lib/sentiero/web/assets/style-d71e72fd.css +2 -0
- data/lib/sentiero/web/assets_app.rb +42 -0
- data/lib/sentiero/web/base_app.rb +319 -0
- data/lib/sentiero/web/basic_auth.rb +27 -0
- data/lib/sentiero/web/basic_auth_check.rb +41 -0
- data/lib/sentiero/web/body_reader.rb +44 -0
- data/lib/sentiero/web/csv_writer.rb +45 -0
- data/lib/sentiero/web/dashboard_app.rb +236 -0
- data/lib/sentiero/web/errors_app.rb +97 -0
- data/lib/sentiero/web/escaping.rb +37 -0
- data/lib/sentiero/web/events_app.rb +196 -0
- data/lib/sentiero/web/formatting.rb +43 -0
- data/lib/sentiero/web/ingest_app.rb +92 -0
- data/lib/sentiero/web/manifest.rb +43 -0
- data/lib/sentiero/web/monitoring_app.rb +316 -0
- data/lib/sentiero/web/script_tag.rb +57 -0
- data/lib/sentiero/web/shareable_replay.rb +88 -0
- data/lib/sentiero/web/templates/_analytics_nav.html.erb +22 -0
- data/lib/sentiero/web/templates/_brand.html.erb +18 -0
- data/lib/sentiero/web/templates/_date_range.html.erb +18 -0
- data/lib/sentiero/web/templates/_errors_client_filter.html.erb +25 -0
- data/lib/sentiero/web/templates/_errors_server_filter.html.erb +36 -0
- data/lib/sentiero/web/templates/_events_browser_filter.html.erb +18 -0
- data/lib/sentiero/web/templates/_events_server_filter.html.erb +39 -0
- data/lib/sentiero/web/templates/_pagination.html.erb +14 -0
- data/lib/sentiero/web/templates/_payload_metrics.html.erb +62 -0
- data/lib/sentiero/web/templates/_session_row.html.erb +42 -0
- data/lib/sentiero/web/templates/_sibling_tab_hint.html.erb +6 -0
- data/lib/sentiero/web/templates/_tabs.html.erb +10 -0
- data/lib/sentiero/web/templates/_truncation_warning.html.erb +19 -0
- data/lib/sentiero/web/templates/_window_tab.html.erb +5 -0
- data/lib/sentiero/web/templates/analytics_conversions.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_engagement.html.erb +101 -0
- data/lib/sentiero/web/templates/analytics_frustration.html.erb +135 -0
- data/lib/sentiero/web/templates/analytics_funnel.html.erb +103 -0
- data/lib/sentiero/web/templates/analytics_index.html.erb +380 -0
- data/lib/sentiero/web/templates/analytics_page.html.erb +287 -0
- data/lib/sentiero/web/templates/analytics_scroll.html.erb +94 -0
- data/lib/sentiero/web/templates/analytics_vitals.html.erb +91 -0
- data/lib/sentiero/web/templates/client_error_show.html.erb +73 -0
- data/lib/sentiero/web/templates/dashboard.html.erb +56 -0
- data/lib/sentiero/web/templates/errors_index.html.erb +149 -0
- data/lib/sentiero/web/templates/event_show.html.erb +52 -0
- data/lib/sentiero/web/templates/events_index.html.erb +177 -0
- data/lib/sentiero/web/templates/export_index.html.erb +69 -0
- data/lib/sentiero/web/templates/forms.html.erb +105 -0
- data/lib/sentiero/web/templates/heatmap.html.erb +76 -0
- data/lib/sentiero/web/templates/import.html.erb +39 -0
- data/lib/sentiero/web/templates/problem_show.html.erb +200 -0
- data/lib/sentiero/web/templates/segments.html.erb +114 -0
- data/lib/sentiero/web/templates/session_show.html.erb +195 -0
- data/lib/sentiero/web/templates/sessions_index.html.erb +97 -0
- data/lib/sentiero/web/track_app.rb +57 -0
- data/lib/sentiero/web/views/analytics_index_view.rb +86 -0
- data/lib/sentiero/web/views/analyzer_view.rb +27 -0
- data/lib/sentiero/web/views/base_view.rb +76 -0
- data/lib/sentiero/web/views/client_error_show_view.rb +29 -0
- data/lib/sentiero/web/views/conversions_view.rb +41 -0
- data/lib/sentiero/web/views/engagement_view.rb +67 -0
- data/lib/sentiero/web/views/errors_index_view.rb +37 -0
- data/lib/sentiero/web/views/event_show_view.rb +20 -0
- data/lib/sentiero/web/views/events_index_view.rb +56 -0
- data/lib/sentiero/web/views/export_view.rb +23 -0
- data/lib/sentiero/web/views/forms_view.rb +28 -0
- data/lib/sentiero/web/views/frustration_view.rb +15 -0
- data/lib/sentiero/web/views/funnel_view.rb +36 -0
- data/lib/sentiero/web/views/heatmap_view.rb +34 -0
- data/lib/sentiero/web/views/import_view.rb +13 -0
- data/lib/sentiero/web/views/page_report_view.rb +43 -0
- data/lib/sentiero/web/views/problem_show_view.rb +46 -0
- data/lib/sentiero/web/views/scroll_view.rb +23 -0
- data/lib/sentiero/web/views/segments_view.rb +28 -0
- data/lib/sentiero/web/views/session_show_view.rb +105 -0
- data/lib/sentiero/web/views/sessions_index_view.rb +28 -0
- data/lib/sentiero/web/views/vitals_view.rb +45 -0
- data/lib/sentiero/web/views.rb +24 -0
- data/lib/sentiero/window_ref.rb +6 -0
- data/lib/sentiero.rb +69 -0
- metadata +232 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
require_relative "body_reader"
|
|
6
|
+
require_relative "../store"
|
|
7
|
+
|
|
8
|
+
module Sentiero
|
|
9
|
+
module Web
|
|
10
|
+
# Base class for the authenticated server-lane ingest apps (eg ErrorsApp, TrackApp).
|
|
11
|
+
# Subclasses implement #handle(env, project, data).
|
|
12
|
+
#
|
|
13
|
+
# Unlike EventsApp (the public browser lane), these require a per-project
|
|
14
|
+
# write-only ingest key: Authorization: Bearer <key>. Keys map to a project
|
|
15
|
+
# via Sentiero.configuration.ingest_keys ({ "<secret>" => "<project>" }).
|
|
16
|
+
class IngestApp
|
|
17
|
+
def call(env)
|
|
18
|
+
return json_response(405, {error: "method not allowed"}) unless env["REQUEST_METHOD"] == "POST"
|
|
19
|
+
|
|
20
|
+
project = authenticate(env)
|
|
21
|
+
return json_response(401, {error: "invalid or missing ingest key"}) unless project
|
|
22
|
+
|
|
23
|
+
body, error = read_body(env)
|
|
24
|
+
return error if error
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
data = JSON.parse(body)
|
|
28
|
+
rescue JSON::ParserError
|
|
29
|
+
return json_response(400, {error: "invalid JSON body"})
|
|
30
|
+
end
|
|
31
|
+
return json_response(400, {error: "body must be a JSON object"}) unless data.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
handle(env, project, data)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Subclass hook; returns a Rack response triple.
|
|
39
|
+
def handle(env, project, data)
|
|
40
|
+
raise NoMethodError, "#{self.class}#handle not implemented"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Resolves the ingest key to a project name, or nil. Constant-time compare
|
|
44
|
+
# so timing can't distinguish a wrong key from a right one of equal length.
|
|
45
|
+
def authenticate(env)
|
|
46
|
+
keys = Sentiero.configuration.ingest_keys
|
|
47
|
+
return nil if keys.nil? || keys.empty?
|
|
48
|
+
|
|
49
|
+
presented = bearer_token(env)
|
|
50
|
+
return nil if presented.nil? || presented.empty?
|
|
51
|
+
|
|
52
|
+
keys.each do |key, project|
|
|
53
|
+
return project if Rack::Utils.secure_compare(key.to_s, presented)
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def bearer_token(env)
|
|
59
|
+
header = env["HTTP_AUTHORIZATION"]
|
|
60
|
+
return nil unless header
|
|
61
|
+
|
|
62
|
+
scheme, token = header.split(" ", 2)
|
|
63
|
+
return nil unless scheme&.downcase == "bearer"
|
|
64
|
+
token&.strip
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def read_body(env)
|
|
68
|
+
raw, error = BodyReader.read(env)
|
|
69
|
+
return [raw, nil] unless error
|
|
70
|
+
|
|
71
|
+
status, message = BodyReader::ERRORS[error]
|
|
72
|
+
[nil, json_response(status, {error: message})]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def json_response(status, hash)
|
|
76
|
+
[status, {"content-type" => "application/json", "x-content-type-options" => "nosniff"}, [JSON.generate(hash)]]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def numeric_timestamp(raw)
|
|
80
|
+
return Time.now.to_f if raw.nil?
|
|
81
|
+
ts = raw.is_a?(Numeric) ? raw.to_f : Float(raw)
|
|
82
|
+
ts.finite? ? ts : Time.now.to_f
|
|
83
|
+
rescue ArgumentError, TypeError
|
|
84
|
+
Time.now.to_f
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def valid_optional_id?(id)
|
|
88
|
+
id.is_a?(String) && id.match?(Store::VALID_ID)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Sentiero
|
|
6
|
+
module Web
|
|
7
|
+
module Manifest
|
|
8
|
+
ASSETS_DIR = File.expand_path("assets", __dir__).freeze
|
|
9
|
+
|
|
10
|
+
def self.manifest
|
|
11
|
+
if @auto_reload
|
|
12
|
+
load_manifest
|
|
13
|
+
else
|
|
14
|
+
@manifest ||= load_manifest
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Re-read manifest from disk on every access (dev, for `npm run watch`).
|
|
19
|
+
def self.auto_reload!
|
|
20
|
+
@auto_reload = true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.asset_path(logical_name, base_path = "")
|
|
24
|
+
filename = manifest[logical_name]
|
|
25
|
+
unless filename
|
|
26
|
+
raise Sentiero::Error, "Unknown asset: #{logical_name}. Run 'cd frontend && npm run build' first."
|
|
27
|
+
end
|
|
28
|
+
"#{base_path}/assets/#{filename}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.reset!
|
|
32
|
+
@manifest = load_manifest
|
|
33
|
+
@auto_reload = false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private_class_method def self.load_manifest
|
|
37
|
+
path = File.join(ASSETS_DIR, "manifest.json")
|
|
38
|
+
return {}.freeze unless File.exist?(path)
|
|
39
|
+
JSON.parse(File.read(path)).freeze
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_app"
|
|
4
|
+
require_relative "../analytics/browser_event_discovery"
|
|
5
|
+
require_relative "../analytics/error_discovery"
|
|
6
|
+
require_relative "../analytics/server_event_metrics"
|
|
7
|
+
require_relative "../analytics/problem_detail"
|
|
8
|
+
|
|
9
|
+
module Sentiero
|
|
10
|
+
module Web
|
|
11
|
+
# Rack app owning the error/issue tracking (/issues/*) and custom-event
|
|
12
|
+
# browsing (/custom-events/*) routes. Mounted at the same point as
|
|
13
|
+
# DashboardApp (which delegates those requests here), so PATH_INFO/SCRIPT_NAME
|
|
14
|
+
# are read from env to preserve base_path.
|
|
15
|
+
class MonitoringApp < BaseApp
|
|
16
|
+
def initialize
|
|
17
|
+
super
|
|
18
|
+
BaseApp.warn_unauthenticated_once
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(env)
|
|
22
|
+
path = env["PATH_INFO"] || "/"
|
|
23
|
+
method = env["REQUEST_METHOD"]
|
|
24
|
+
|
|
25
|
+
return unauthorized_response unless authorized?(env)
|
|
26
|
+
|
|
27
|
+
case path
|
|
28
|
+
when "/custom-events"
|
|
29
|
+
handle_events_index(env)
|
|
30
|
+
when %r{\A/custom-events/([^/]+)\z}
|
|
31
|
+
event_id = $1
|
|
32
|
+
get_only(method) || guard(event_id) || handle_event_show(env, event_id)
|
|
33
|
+
when "/issues"
|
|
34
|
+
handle_errors_index(env)
|
|
35
|
+
when %r{\A/issues/client/([^/]+)\z}
|
|
36
|
+
# Matched BEFORE the generic /issues/:id case so "client" isn't taken as
|
|
37
|
+
# a server fingerprint. Client error ids are ErrorDiscovery group digests,
|
|
38
|
+
# not store ids, so there is no id guard here.
|
|
39
|
+
id = $1
|
|
40
|
+
get_only(method) || handle_client_error_show(env, id)
|
|
41
|
+
when %r{\A/issues/([^/]+)/status\z}
|
|
42
|
+
problem_id = $1
|
|
43
|
+
post_only(method) || guard(problem_id) || handle_error_status(env, problem_id)
|
|
44
|
+
when %r{\A/issues/([^/]+)\z}
|
|
45
|
+
problem_id = $1
|
|
46
|
+
get_only(method) || guard(problem_id) || handle_error_show(env, problem_id)
|
|
47
|
+
else
|
|
48
|
+
not_found
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def handle_event_show(env, event_id)
|
|
55
|
+
event = Sentiero.store.get_server_event(event_id)
|
|
56
|
+
return not_found if event.nil?
|
|
57
|
+
|
|
58
|
+
audit!(env, action: :view_event)
|
|
59
|
+
render_page(env, Views::EventShowView.new(event: event), csrf: false)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def handle_events_index(env)
|
|
63
|
+
params = query_params(env)
|
|
64
|
+
return handle_browser_events(env) if params["source"] == "browser"
|
|
65
|
+
|
|
66
|
+
level = %w[debug info warn error].include?(params["level"]) ? params["level"] : nil
|
|
67
|
+
search = params["search"]
|
|
68
|
+
project_param = params["project"]
|
|
69
|
+
project_param = nil if project_param&.empty?
|
|
70
|
+
since, until_time = parse_range_params(params)
|
|
71
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
72
|
+
|
|
73
|
+
page, per_page, offset = paginate(params, default: 50, max: 200)
|
|
74
|
+
|
|
75
|
+
events = filtered_server_events(level: level, project: project_param, search: search, since: since, until_time: until_time)
|
|
76
|
+
page_events, has_next = take_page(events.slice(offset, per_page + 1) || [], per_page)
|
|
77
|
+
|
|
78
|
+
audit!(env, action: :list_events)
|
|
79
|
+
|
|
80
|
+
projects = (Sentiero.configuration.ingest_keys || {}).values.uniq.sort
|
|
81
|
+
|
|
82
|
+
sibling = if events.empty? && level.nil? && search.to_s.empty? && project_param.nil? && since.nil? && until_time.nil?
|
|
83
|
+
result = Sentiero::Analytics::BrowserEventDiscovery.new(Sentiero.store).recent_events
|
|
84
|
+
{count: result[:rows].size, capped: result[:was_truncated]}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# level_mix and payload metrics compute over the full pre-pagination list,
|
|
88
|
+
# so the strips describe the whole filtered range, not one page.
|
|
89
|
+
metrics = Sentiero::Analytics::ServerEventMetrics.new(events)
|
|
90
|
+
render_page(env, Views::EventsIndexView.new(
|
|
91
|
+
events: page_events,
|
|
92
|
+
level: level || "",
|
|
93
|
+
search: search || "",
|
|
94
|
+
project: project_param || "",
|
|
95
|
+
projects: projects,
|
|
96
|
+
since_param: since_param,
|
|
97
|
+
until_param: until_param,
|
|
98
|
+
level_mix: metrics.level_mix_by_day,
|
|
99
|
+
page: page,
|
|
100
|
+
per_page: per_page,
|
|
101
|
+
has_next: has_next,
|
|
102
|
+
sibling: sibling,
|
|
103
|
+
**metrics.payload_metric_locals(params["metric_key"])
|
|
104
|
+
), csrf: false)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Bounded server-event fetch for the events index, request filters applied.
|
|
108
|
+
# `since` rides the store's after: param (strict >, equivalent for a midnight
|
|
109
|
+
# bound); `until` and name search filter in Ruby. Newest first for display.
|
|
110
|
+
def filtered_server_events(level:, project:, search:, since:, until_time:)
|
|
111
|
+
events = Sentiero.store.list_server_events(project: project, limit: 10_000, level: level, after: since)
|
|
112
|
+
events = events.select { |e| e["timestamp"].to_f <= until_time } if until_time
|
|
113
|
+
if search && !search.empty?
|
|
114
|
+
term = search.downcase
|
|
115
|
+
events = events.select { |e| e["name"].to_s.downcase.include?(term) }
|
|
116
|
+
end
|
|
117
|
+
events.reverse
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Bound on the store-list calls behind sibling-tab counts in empty states;
|
|
121
|
+
# a count that hits the cap renders as "500+".
|
|
122
|
+
SIBLING_COUNT_LIMIT = 500
|
|
123
|
+
|
|
124
|
+
def handle_browser_events(env)
|
|
125
|
+
params = query_params(env)
|
|
126
|
+
search = params["search"]
|
|
127
|
+
since, until_time = parse_range_params(params)
|
|
128
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
129
|
+
|
|
130
|
+
page, per_page, offset = paginate(params, default: 50, max: 200)
|
|
131
|
+
|
|
132
|
+
result = Sentiero::Analytics::BrowserEventDiscovery.new(Sentiero.store)
|
|
133
|
+
.recent_events(since: since, until_time: until_time)
|
|
134
|
+
rows = result[:rows]
|
|
135
|
+
if search && !search.empty?
|
|
136
|
+
term = search.downcase
|
|
137
|
+
rows = rows.select { |r| r[:name].to_s.downcase.include?(term) }
|
|
138
|
+
end
|
|
139
|
+
page_rows, has_next = take_page(rows.slice(offset, per_page + 1) || [], per_page)
|
|
140
|
+
|
|
141
|
+
# Bounded count of the sibling server-events tab for the empty-state cross-link.
|
|
142
|
+
sibling = if rows.empty? && search.to_s.empty? && since.nil? && until_time.nil?
|
|
143
|
+
server_events = Sentiero.store.list_server_events(project: nil, limit: SIBLING_COUNT_LIMIT)
|
|
144
|
+
{count: server_events.size, capped: server_events.size >= SIBLING_COUNT_LIMIT}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
audit!(env, action: :list_events)
|
|
148
|
+
render_page(env, Views::EventsIndexView.new(
|
|
149
|
+
source: "browser",
|
|
150
|
+
browser_rows: page_rows,
|
|
151
|
+
search: search || "",
|
|
152
|
+
since_param: since_param,
|
|
153
|
+
until_param: until_param,
|
|
154
|
+
page: page,
|
|
155
|
+
per_page: per_page,
|
|
156
|
+
has_next: has_next,
|
|
157
|
+
was_truncated: result[:was_truncated],
|
|
158
|
+
sibling: sibling,
|
|
159
|
+
**Sentiero::Analytics::ServerEventMetrics.new(
|
|
160
|
+
Sentiero::Analytics::ServerEventMetrics.adapt_browser_rows(rows)
|
|
161
|
+
).payload_metric_locals(params["metric_key"])
|
|
162
|
+
), csrf: false)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Unified errors listing. `?source=client` renders aggregated client-side JS
|
|
166
|
+
# errors; otherwise the server-exception ("problems") listing. The server
|
|
167
|
+
# branch sets the CSRF cookie for inline resolve/ignore.
|
|
168
|
+
def handle_errors_index(env)
|
|
169
|
+
params = query_params(env)
|
|
170
|
+
source = (params["source"] == "client") ? "client" : "server"
|
|
171
|
+
if source == "client"
|
|
172
|
+
handle_client_errors_index(env, params)
|
|
173
|
+
else
|
|
174
|
+
handle_server_errors_index(env, params)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def handle_server_errors_index(env, params)
|
|
179
|
+
status = %w[open resolved ignored].include?(params["status"]) ? params["status"] : nil
|
|
180
|
+
search = params["search"]
|
|
181
|
+
sort_by = %w[last_seen first_seen count].include?(params["sort_by"]) ? params["sort_by"] : "last_seen"
|
|
182
|
+
since, until_time = parse_range_params(params)
|
|
183
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
184
|
+
|
|
185
|
+
page, per_page, offset = paginate(params, default: 50, max: 200)
|
|
186
|
+
problems = Sentiero.store.list_problems(project: nil, limit: per_page + 1, offset: offset,
|
|
187
|
+
status: status, sort_by: sort_by, search: search, since: since, until_time: until_time)
|
|
188
|
+
problems, has_next = take_page(problems, per_page)
|
|
189
|
+
|
|
190
|
+
audit!(env, action: :list_problems)
|
|
191
|
+
|
|
192
|
+
sibling = if problems.empty? && page == 1 && status.nil? && search.to_s.empty? && since.nil? && until_time.nil?
|
|
193
|
+
result = Sentiero::Analytics::ErrorDiscovery.new(Sentiero.store).grouped_errors
|
|
194
|
+
{count: result[:groups].size, capped: result[:was_truncated]}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
render_page(env, Views::ErrorsIndexView.new(
|
|
198
|
+
source: "server",
|
|
199
|
+
problems: problems,
|
|
200
|
+
sibling: sibling,
|
|
201
|
+
status: status || "",
|
|
202
|
+
search: search || "",
|
|
203
|
+
sort_by: sort_by,
|
|
204
|
+
since_param: since_param,
|
|
205
|
+
until_param: until_param,
|
|
206
|
+
new_since: since,
|
|
207
|
+
page: page,
|
|
208
|
+
per_page: per_page,
|
|
209
|
+
has_next: has_next
|
|
210
|
+
))
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def handle_client_errors_index(env, params)
|
|
214
|
+
sort_by = %w[count recency].include?(params["sort_by"]) ? params["sort_by"] : "count"
|
|
215
|
+
search = params["search"]
|
|
216
|
+
since, until_time = parse_range_params(params)
|
|
217
|
+
since_param, until_param = echo_range_params(params, since, until_time)
|
|
218
|
+
|
|
219
|
+
page, per_page, offset = paginate(params, default: 50, max: 200)
|
|
220
|
+
|
|
221
|
+
result = Sentiero::Analytics::ErrorDiscovery.new(Sentiero.store)
|
|
222
|
+
.grouped_errors(sort_by: sort_by, since: since, until_time: until_time)
|
|
223
|
+
groups = result[:groups]
|
|
224
|
+
if search && !search.empty?
|
|
225
|
+
term = search.downcase
|
|
226
|
+
groups = groups.select { |g| g[:message].to_s.downcase.include?(term) }
|
|
227
|
+
end
|
|
228
|
+
page_groups, has_next = take_page(groups.slice(offset, per_page + 1) || [], per_page)
|
|
229
|
+
|
|
230
|
+
audit!(env, action: :list_problems)
|
|
231
|
+
|
|
232
|
+
# Bounded count of the sibling server-exceptions tab for the empty-state cross-link.
|
|
233
|
+
sibling = if groups.empty? && search.to_s.empty? && since.nil? && until_time.nil?
|
|
234
|
+
problems = Sentiero.store.list_problems(project: nil, limit: SIBLING_COUNT_LIMIT)
|
|
235
|
+
{count: problems.size, capped: problems.size >= SIBLING_COUNT_LIMIT}
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
render_page(env, Views::ErrorsIndexView.new(
|
|
239
|
+
source: "client",
|
|
240
|
+
groups: page_groups,
|
|
241
|
+
sibling: sibling,
|
|
242
|
+
sort_by: sort_by,
|
|
243
|
+
search: search || "",
|
|
244
|
+
since_param: since_param,
|
|
245
|
+
until_param: until_param,
|
|
246
|
+
page: page,
|
|
247
|
+
per_page: per_page,
|
|
248
|
+
has_next: has_next,
|
|
249
|
+
was_truncated: result[:was_truncated]
|
|
250
|
+
))
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Client-side JS error detail page. Re-runs ErrorDiscovery and finds the
|
|
254
|
+
# group whose stable :id matches.
|
|
255
|
+
def handle_client_error_show(env, id)
|
|
256
|
+
result = Sentiero::Analytics::ErrorDiscovery.new(Sentiero.store).grouped_errors
|
|
257
|
+
group = result[:groups].find { |g| g[:id] == id }
|
|
258
|
+
return not_found if group.nil?
|
|
259
|
+
|
|
260
|
+
audit!(env, action: :view_client_error)
|
|
261
|
+
|
|
262
|
+
render_page(env, Views::ClientErrorShowView.new(group: group, was_truncated: result[:was_truncated]), csrf: false)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def handle_error_show(env, problem_id)
|
|
266
|
+
problem = Sentiero.store.get_problem(problem_id)
|
|
267
|
+
return not_found if problem.nil?
|
|
268
|
+
|
|
269
|
+
occurrences = Sentiero.store.get_occurrences(problem_id, limit: 50).reverse # newest first
|
|
270
|
+
session_ids = Sentiero.store.session_ids_for_problem(problem_id, limit: 50)
|
|
271
|
+
|
|
272
|
+
session_summaries = session_ids.map do |sid|
|
|
273
|
+
session = Sentiero.store.get_session(sid)
|
|
274
|
+
if session
|
|
275
|
+
first_window = (session[:windows] || []).first
|
|
276
|
+
ua = session.dig(:metadata, "userAgent")
|
|
277
|
+
{
|
|
278
|
+
session_id: sid,
|
|
279
|
+
first_event_at: session[:first_event_at],
|
|
280
|
+
last_event_at: session[:last_event_at],
|
|
281
|
+
browser: ua ? parse_browser(ua) : nil,
|
|
282
|
+
window_id: first_window ? first_window[:window_id] : nil
|
|
283
|
+
}
|
|
284
|
+
else
|
|
285
|
+
{session_id: sid, first_event_at: nil, last_event_at: nil, browser: nil, window_id: nil}
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
audit!(env, action: :view_problem, problem_id: problem_id)
|
|
290
|
+
|
|
291
|
+
detail = Sentiero::Analytics::ProblemDetail.new(Sentiero.store)
|
|
292
|
+
render_page(env, Views::ProblemShowView.new(
|
|
293
|
+
problem: problem,
|
|
294
|
+
occurrences: occurrences,
|
|
295
|
+
session_ids: session_ids,
|
|
296
|
+
session_summaries: session_summaries,
|
|
297
|
+
facets: detail.facets(occurrences, session_summaries),
|
|
298
|
+
trend: detail.trend(problem_id, occurrences)
|
|
299
|
+
))
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def handle_error_status(env, problem_id)
|
|
303
|
+
request = Rack::Request.new(env)
|
|
304
|
+
return forbidden_csrf unless valid_csrf_token?(env, request.POST["csrf_token"])
|
|
305
|
+
|
|
306
|
+
status = request.POST["status"]
|
|
307
|
+
return [400, {"content-type" => "text/plain"}, ["bad status"]] unless %w[open resolved ignored].include?(status)
|
|
308
|
+
|
|
309
|
+
Sentiero.store.update_problem_status(problem_id, status)
|
|
310
|
+
audit!(env, action: :update_problem_status, problem_id: problem_id)
|
|
311
|
+
|
|
312
|
+
redirect("#{base_path(env)}/issues/#{problem_id}", status: 303)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "escaping"
|
|
5
|
+
|
|
6
|
+
module Sentiero
|
|
7
|
+
module Web
|
|
8
|
+
module ScriptTag
|
|
9
|
+
extend Escaping
|
|
10
|
+
|
|
11
|
+
def self.render(events_url:, recorder_url: nil)
|
|
12
|
+
config = Sentiero.configuration
|
|
13
|
+
|
|
14
|
+
recorder_url ||= default_recorder_url(events_url)
|
|
15
|
+
|
|
16
|
+
json_data = {
|
|
17
|
+
eventsUrl: events_url,
|
|
18
|
+
flushIntervalMs: config.flush_interval_ms,
|
|
19
|
+
flushEventThreshold: config.flush_event_threshold,
|
|
20
|
+
recorderOptions: config.effective_recorder_options,
|
|
21
|
+
crossTabSessions: config.cross_tab_sessions,
|
|
22
|
+
redaction: config.redaction.to_client_hash,
|
|
23
|
+
# Seconds on the Ruby side (matches retention_period's unit); ms on
|
|
24
|
+
# the wire so the client can compare directly against Date.now().
|
|
25
|
+
sessionIdleTimeoutMs: config.session_idle_timeout * 1000,
|
|
26
|
+
sessionMaxAgeMs: config.session_max_age * 1000
|
|
27
|
+
}
|
|
28
|
+
json_data[:captureMetadata] = true if config.capture_metadata
|
|
29
|
+
json_data[:captureErrors] = true if config.capture_errors
|
|
30
|
+
json_data[:trackNavigation] = true if config.track_navigation
|
|
31
|
+
json_data[:trackCustomEvents] = true if config.track_custom_events
|
|
32
|
+
json_data[:captureWebVitals] = true if config.capture_web_vitals
|
|
33
|
+
json_data[:captureClicks] = true if config.capture_clicks
|
|
34
|
+
json_data[:trackForms] = true if config.track_forms
|
|
35
|
+
json_data[:optOutCookieName] = config.opt_out_cookie_name if config.user_opt_out
|
|
36
|
+
json_data[:respectGpc] = true if config.respect_gpc
|
|
37
|
+
|
|
38
|
+
config_json = JSON.generate(json_data)
|
|
39
|
+
|
|
40
|
+
safe_json = escape_json(config_json)
|
|
41
|
+
escaped_recorder_url = escape_html(recorder_url.to_s)
|
|
42
|
+
|
|
43
|
+
<<~HTML
|
|
44
|
+
<script type="application/json" id="sentiero-config">#{safe_json}</script>
|
|
45
|
+
<script src="#{escaped_recorder_url}"></script>
|
|
46
|
+
HTML
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.default_recorder_url(events_url)
|
|
50
|
+
base = events_url.sub(%r{/events\z}, "")
|
|
51
|
+
Sentiero::Web::Manifest.asset_path("recorder", base)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private_class_method :default_recorder_url
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base_app"
|
|
5
|
+
require_relative "manifest"
|
|
6
|
+
|
|
7
|
+
module Sentiero
|
|
8
|
+
module Web
|
|
9
|
+
# Builds a single self-contained HTML document for a whole session, inlining
|
|
10
|
+
# the rrweb-player JS/CSS and the session's events so it replays offline with
|
|
11
|
+
# no server. #html returns the document, or nil when there's nothing to replay.
|
|
12
|
+
#
|
|
13
|
+
# Events are inlined as a <script type="application/json"> blob escaped via
|
|
14
|
+
# escape_json so a </script> in the data cannot break out of the script context.
|
|
15
|
+
class ShareableReplay
|
|
16
|
+
include Escaping
|
|
17
|
+
|
|
18
|
+
def initialize(store, session_id)
|
|
19
|
+
@store = store
|
|
20
|
+
@session_id = session_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def html
|
|
24
|
+
session = @store.get_session(@session_id)
|
|
25
|
+
return nil if session.nil?
|
|
26
|
+
|
|
27
|
+
windows = session[:windows] || []
|
|
28
|
+
return nil if windows.empty?
|
|
29
|
+
|
|
30
|
+
events = collect_events(windows)
|
|
31
|
+
return nil if events.empty?
|
|
32
|
+
|
|
33
|
+
build_html(events)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# rrweb replays a flat, time-ordered stream, so all windows are merged
|
|
39
|
+
# and sorted by timestamp into one timeline.
|
|
40
|
+
def collect_events(windows)
|
|
41
|
+
windows
|
|
42
|
+
.flat_map { |window| @store.get_events(Sentiero::WindowRef.new(@session_id, window[:window_id])) }
|
|
43
|
+
.sort_by { |event| event["timestamp"] || 0 }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_html(events)
|
|
47
|
+
<<~HTML
|
|
48
|
+
<!DOCTYPE html>
|
|
49
|
+
<html lang="en">
|
|
50
|
+
<head>
|
|
51
|
+
<meta charset="utf-8">
|
|
52
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
53
|
+
<title>Sentiero session #{escape_html(@session_id)}</title>
|
|
54
|
+
<style>#{read_asset("rrweb-player-css")}</style>
|
|
55
|
+
<style>body{margin:0;background:#1a1a1a}#sentiero-player{display:flex;justify-content:center;padding:16px}</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<div id="sentiero-player"></div>
|
|
59
|
+
<script type="application/json" id="sentiero-events">#{escape_json(JSON.generate(events))}</script>
|
|
60
|
+
<script>#{read_asset("rrweb-player")}</script>
|
|
61
|
+
<script>#{bootloader}</script>
|
|
62
|
+
</body>
|
|
63
|
+
</html>
|
|
64
|
+
HTML
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# JSON.parse safely round-trips the escape_json transform, which only
|
|
68
|
+
# touched <, >, & and the JS line separators (all valid in JSON strings).
|
|
69
|
+
def bootloader
|
|
70
|
+
<<~JS
|
|
71
|
+
(function () {
|
|
72
|
+
var events = JSON.parse(document.getElementById("sentiero-events").textContent);
|
|
73
|
+
var Player = rrwebPlayer.default || rrwebPlayer;
|
|
74
|
+
new Player({
|
|
75
|
+
target: document.getElementById("sentiero-player"),
|
|
76
|
+
props: { events: events, autoPlay: false }
|
|
77
|
+
});
|
|
78
|
+
})();
|
|
79
|
+
JS
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_asset(logical_name)
|
|
83
|
+
filename = Manifest.manifest.fetch(logical_name)
|
|
84
|
+
File.read(File.join(BaseApp::ASSETS_DIR, filename))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<%
|
|
2
|
+
range_pairs = {}
|
|
3
|
+
range_pairs["since"] = since if since && !since.to_s.empty?
|
|
4
|
+
range_pairs["until"] = until_str if until_str && !until_str.to_s.empty?
|
|
5
|
+
range_qs = range_pairs.empty? ? "" : "?" + Rack::Utils.build_query(range_pairs)
|
|
6
|
+
|
|
7
|
+
nav_tabs = [
|
|
8
|
+
{label: "Overview", href: "#{view.base_path}/analytics#{range_qs}", active: active == :overview},
|
|
9
|
+
{label: "Heatmap", href: "#{view.base_path}/analytics/heatmap#{range_qs}", active: active == :heatmap},
|
|
10
|
+
{label: "Scroll", href: "#{view.base_path}/analytics/scroll#{range_qs}", active: active == :scroll},
|
|
11
|
+
{label: "Forms", href: "#{view.base_path}/analytics/forms#{range_qs}", active: active == :forms},
|
|
12
|
+
{label: "Pages", href: "#{view.base_path}/analytics/page#{range_qs}", active: active == :pages},
|
|
13
|
+
{label: "Segments", href: "#{view.base_path}/analytics/segments#{range_qs}", active: active == :segments},
|
|
14
|
+
{label: "Web Vitals", href: "#{view.base_path}/analytics/vitals#{range_qs}", active: active == :vitals},
|
|
15
|
+
{label: "Frustration", href: "#{view.base_path}/analytics/frustration#{range_qs}", active: active == :frustration},
|
|
16
|
+
{label: "Funnel", href: "#{view.base_path}/analytics/funnel#{range_qs}", active: active == :funnel},
|
|
17
|
+
{label: "Engagement", href: "#{view.base_path}/analytics/engagement#{range_qs}", active: active == :engagement},
|
|
18
|
+
{label: "Conversions", href: "#{view.base_path}/analytics/conversions#{range_qs}", active: active == :conversions},
|
|
19
|
+
{label: "Export", href: "#{view.base_path}/analytics/export#{range_qs}", active: active == :export}
|
|
20
|
+
]
|
|
21
|
+
-%>
|
|
22
|
+
<%= view.render_partial("_tabs.html.erb", tabs: nav_tabs) %>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<a class="s-sidebar-brand" href="<%= view.h(view.base_path) %>/">
|
|
2
|
+
<svg class="s-sidebar-brand-logo" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 100 100" fill="none" role="img" aria-label="Sentiero">
|
|
3
|
+
<rect width="100" height="100" rx="22" fill="#ff6b5a"/>
|
|
4
|
+
<path d="M28 76 C42 76, 68 68, 68 56 C68 44, 30 42, 30 32 C30 24, 44 18, 58 18" stroke="white" stroke-width="3.8" stroke-linecap="round" fill="none"/>
|
|
5
|
+
<path d="M66 60 C70 64, 74 68, 76 72" stroke="white" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.28" stroke-dasharray="4 3.5"/>
|
|
6
|
+
<circle cx="76" cy="72" r="3" fill="white" opacity="0.18"/>
|
|
7
|
+
<path d="M32 36 C26 32, 22 28, 20 24" stroke="white" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.24" stroke-dasharray="4 3.5"/>
|
|
8
|
+
<circle cx="20" cy="24" r="3" fill="white" opacity="0.16"/>
|
|
9
|
+
<path d="M48 48 C40 52, 30 56, 22 56" stroke="white" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.22" stroke-dasharray="4 3.5"/>
|
|
10
|
+
<circle cx="22" cy="56" r="2.5" fill="white" opacity="0.15"/>
|
|
11
|
+
<path d="M52 44 C58 42, 66 40, 74 42" stroke="white" stroke-width="1.5" stroke-linecap="round" fill="none" opacity="0.25" stroke-dasharray="4 3.5"/>
|
|
12
|
+
<circle cx="74" cy="42" r="2.5" fill="white" opacity="0.16"/>
|
|
13
|
+
<line x1="62" y1="13" x2="70" y2="21" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
|
14
|
+
<line x1="70" y1="13" x2="62" y2="21" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
|
15
|
+
<circle cx="28" cy="76" r="3" fill="white" opacity="0.35"/>
|
|
16
|
+
</svg>
|
|
17
|
+
Sentiero
|
|
18
|
+
</a>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
Shared From/To date-input pair for the standard `since`/`until` range
|
|
3
|
+
params (parsed UTC; the To-day is inclusive). Embed inside a GET form that
|
|
4
|
+
supplies the action and any other filter fields.
|
|
5
|
+
|
|
6
|
+
Locals: since (string), until_str (string); optional from_label / to_label
|
|
7
|
+
when the filtered column needs calling out (e.g. "Last seen from").
|
|
8
|
+
-%>
|
|
9
|
+
<% from_label = (defined?(from_label) && from_label) ? from_label : "From" -%>
|
|
10
|
+
<% to_label = (defined?(to_label) && to_label) ? to_label : "To" -%>
|
|
11
|
+
<div class="shrink-0">
|
|
12
|
+
<label for="since" class="label"><%= view.h(from_label) %></label>
|
|
13
|
+
<input type="date" name="since" id="since" class="input" style="width:8.5rem" value="<%= view.h(since) %>">
|
|
14
|
+
</div>
|
|
15
|
+
<div class="shrink-0">
|
|
16
|
+
<label for="until" class="label"><%= view.h(to_label) %></label>
|
|
17
|
+
<input type="date" name="until" id="until" class="input" style="width:8.5rem" value="<%= view.h(until_str) %>">
|
|
18
|
+
</div>
|