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,25 @@
|
|
|
1
|
+
<form method="get" action="<%= view.h(view.base_path) %>/issues">
|
|
2
|
+
<input type="hidden" name="source" value="client">
|
|
3
|
+
<div class="card mb-4">
|
|
4
|
+
<div class="flex flex-wrap items-end gap-3 px-4 py-3">
|
|
5
|
+
<div class="shrink-0">
|
|
6
|
+
<label for="search" class="label">Search</label>
|
|
7
|
+
<input type="text" name="search" id="search" class="input w-56"
|
|
8
|
+
value="<%= view.h(search) %>" placeholder="Error message…">
|
|
9
|
+
</div>
|
|
10
|
+
<div class="shrink-0">
|
|
11
|
+
<label for="sort_by" class="label">Sort by</label>
|
|
12
|
+
<select name="sort_by" id="sort_by" class="select w-36">
|
|
13
|
+
<option value="count" <%= "selected" if sort_by == "count" %>>Count</option>
|
|
14
|
+
<option value="recency" <%= "selected" if sort_by == "recency" %>>Recency</option>
|
|
15
|
+
</select>
|
|
16
|
+
</div>
|
|
17
|
+
<%= view.render_partial("_date_range.html.erb", since: since_param, until_str: until_param) %>
|
|
18
|
+
<div class="shrink-0 flex items-center gap-1.5">
|
|
19
|
+
<input type="hidden" name="per_page" value="<%= per_page %>">
|
|
20
|
+
<button type="submit" class="btn btn-primary">Filter</button>
|
|
21
|
+
<a href="<%= view.h(view.base_path) %>/issues?source=client" class="btn btn-secondary">Clear</a>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</form>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<form method="get" action="<%= view.h(view.base_path) %>/issues">
|
|
2
|
+
<div class="card mb-4">
|
|
3
|
+
<div class="flex flex-wrap items-end gap-3 px-4 py-3">
|
|
4
|
+
<div class="shrink-0">
|
|
5
|
+
<label for="search" class="label">Search</label>
|
|
6
|
+
<input type="text" name="search" id="search" class="input w-56"
|
|
7
|
+
value="<%= view.h(search) %>" placeholder="Exception class or title…">
|
|
8
|
+
</div>
|
|
9
|
+
<div class="shrink-0">
|
|
10
|
+
<label for="status" class="label">Status</label>
|
|
11
|
+
<select name="status" id="status" class="select w-36">
|
|
12
|
+
<option value="" <%= "selected" if status.empty? %>>All</option>
|
|
13
|
+
<option value="open" <%= "selected" if status == "open" %>>Open</option>
|
|
14
|
+
<option value="resolved" <%= "selected" if status == "resolved" %>>Resolved</option>
|
|
15
|
+
<option value="ignored" <%= "selected" if status == "ignored" %>>Ignored</option>
|
|
16
|
+
</select>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="shrink-0">
|
|
19
|
+
<label for="sort_by" class="label">Sort by</label>
|
|
20
|
+
<select name="sort_by" id="sort_by" class="select w-36">
|
|
21
|
+
<option value="last_seen" <%= "selected" if sort_by == "last_seen" %>>Last seen</option>
|
|
22
|
+
<option value="first_seen" <%= "selected" if sort_by == "first_seen" %>>First seen</option>
|
|
23
|
+
<option value="count" <%= "selected" if sort_by == "count" %>>Count</option>
|
|
24
|
+
</select>
|
|
25
|
+
</div>
|
|
26
|
+
<%= view.render_partial("_date_range.html.erb",
|
|
27
|
+
since: since_param, until_str: until_param,
|
|
28
|
+
from_label: "Last seen from", to_label: "Last seen to") %>
|
|
29
|
+
<div class="shrink-0 flex items-center gap-1.5">
|
|
30
|
+
<input type="hidden" name="per_page" value="<%= per_page %>">
|
|
31
|
+
<button type="submit" class="btn btn-primary">Filter</button>
|
|
32
|
+
<a href="<%= view.h(view.base_path) %>/issues" class="btn btn-secondary">Clear</a>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</form>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<form method="get" action="<%= view.h(view.base_path) %>/custom-events">
|
|
2
|
+
<input type="hidden" name="source" value="browser">
|
|
3
|
+
<div class="card mb-4">
|
|
4
|
+
<div class="flex flex-wrap items-end gap-3 px-4 py-3">
|
|
5
|
+
<div class="shrink-0">
|
|
6
|
+
<label for="search" class="label">Search</label>
|
|
7
|
+
<input type="text" name="search" id="search" class="input w-56"
|
|
8
|
+
value="<%= view.h(search) %>" placeholder="Event name…">
|
|
9
|
+
</div>
|
|
10
|
+
<%= view.render_partial("_date_range.html.erb", since: since_param, until_str: until_param) %>
|
|
11
|
+
<div class="shrink-0 flex items-center gap-1.5">
|
|
12
|
+
<input type="hidden" name="per_page" value="<%= per_page %>">
|
|
13
|
+
<button type="submit" class="btn btn-primary">Filter</button>
|
|
14
|
+
<a href="<%= view.h(view.base_path) %>/custom-events?source=browser" class="btn btn-secondary">Clear</a>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
</form>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<form method="get" action="<%= view.h(view.base_path) %>/custom-events">
|
|
2
|
+
<div class="card mb-4">
|
|
3
|
+
<div class="flex flex-wrap items-end gap-3 px-4 py-3">
|
|
4
|
+
<div class="shrink-0">
|
|
5
|
+
<label for="search" class="label">Search</label>
|
|
6
|
+
<input type="text" name="search" id="search" class="input w-56"
|
|
7
|
+
value="<%= view.h(search) %>" placeholder="Event name…">
|
|
8
|
+
</div>
|
|
9
|
+
<div class="shrink-0">
|
|
10
|
+
<label for="level" class="label">Level</label>
|
|
11
|
+
<select name="level" id="level" class="select w-36">
|
|
12
|
+
<option value="" <%= "selected" if level.empty? %>>All</option>
|
|
13
|
+
<option value="debug" <%= "selected" if level == "debug" %>>debug</option>
|
|
14
|
+
<option value="info" <%= "selected" if level == "info" %>>info</option>
|
|
15
|
+
<option value="warn" <%= "selected" if level == "warn" %>>warn</option>
|
|
16
|
+
<option value="error" <%= "selected" if level == "error" %>>error</option>
|
|
17
|
+
</select>
|
|
18
|
+
</div>
|
|
19
|
+
<% if projects && !projects.empty? -%>
|
|
20
|
+
<div class="shrink-0">
|
|
21
|
+
<label for="project" class="label">Project</label>
|
|
22
|
+
<select name="project" id="project" class="select w-40">
|
|
23
|
+
<option value="" <%= "selected" if project.empty? %>>All</option>
|
|
24
|
+
<% projects.each do |proj| -%>
|
|
25
|
+
<option value="<%= view.h(proj) %>" <%= "selected" if project == proj %>><%= view.h(proj) %></option>
|
|
26
|
+
<% end -%>
|
|
27
|
+
</select>
|
|
28
|
+
</div>
|
|
29
|
+
<% end -%>
|
|
30
|
+
<%= view.render_partial("_date_range.html.erb",
|
|
31
|
+
since: since_param, until_str: until_param) %>
|
|
32
|
+
<div class="shrink-0 flex items-center gap-1.5">
|
|
33
|
+
<input type="hidden" name="per_page" value="<%= per_page %>">
|
|
34
|
+
<button type="submit" class="btn btn-primary">Filter</button>
|
|
35
|
+
<a href="<%= view.h(view.base_path) %>/custom-events" class="btn btn-secondary">Clear</a>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</form>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<%
|
|
2
|
+
prev_qs = Rack::Utils.build_query(params.merge("page" => (page - 1).to_s))
|
|
3
|
+
next_qs = Rack::Utils.build_query(params.merge("page" => (page + 1).to_s))
|
|
4
|
+
-%>
|
|
5
|
+
<nav aria-label="Pagination" class="mt-3">
|
|
6
|
+
<ul class="pagination">
|
|
7
|
+
<li class="<%= "page-disabled" if page <= 1 %>">
|
|
8
|
+
<a class="page-link" href="<%= view.h(base_href) %>?<%= prev_qs %>">Previous</a>
|
|
9
|
+
</li>
|
|
10
|
+
<li class="<%= "page-disabled" unless has_next %>">
|
|
11
|
+
<a class="page-link" href="<%= view.h(base_href) %>?<%= next_qs %>">Next</a>
|
|
12
|
+
</li>
|
|
13
|
+
</ul>
|
|
14
|
+
</nav>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<%# Numeric payload metrics, offered when the filters narrowed the
|
|
2
|
+
fetched rows to one event name; computed over those same rows. Shared by
|
|
3
|
+
the server and browser tabs of the events index — `hidden_params` carries
|
|
4
|
+
the active tab's filter state into the metric-key form (incl.
|
|
5
|
+
source=browser on the browser tab) so choosing a key stays on the tab. -%>
|
|
6
|
+
<% if single_name && !metric_keys.empty? -%>
|
|
7
|
+
<div class="card mb-4">
|
|
8
|
+
<div class="card-body">
|
|
9
|
+
<h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-3">Payload Metrics
|
|
10
|
+
<span class="font-normal normal-case tracking-normal text-gray-400">—
|
|
11
|
+
<code class="font-mono normal-case"><%= view.h(single_name) %></code>, numeric payload keys by UTC day</span>
|
|
12
|
+
</h6>
|
|
13
|
+
|
|
14
|
+
<form method="get" action="<%= view.h(view.base_path) %>/custom-events" class="flex items-end gap-2 mb-3">
|
|
15
|
+
<% hidden_params.reject { |_key, value| value.to_s.empty? }.each do |key, value| -%>
|
|
16
|
+
<input type="hidden" name="<%= view.h(key) %>" value="<%= view.h(value) %>">
|
|
17
|
+
<% end -%>
|
|
18
|
+
<div class="shrink-0">
|
|
19
|
+
<label for="metric_key" class="label">Payload key</label>
|
|
20
|
+
<select name="metric_key" id="metric_key" class="select" style="width:12rem">
|
|
21
|
+
<option value="">Choose a key…</option>
|
|
22
|
+
<% metric_keys.each do |key| -%>
|
|
23
|
+
<option value="<%= view.h(key) %>" <%= (metric_key == key) ? "selected" : "" %>><%= view.h(key) %></option>
|
|
24
|
+
<% end -%>
|
|
25
|
+
</select>
|
|
26
|
+
</div>
|
|
27
|
+
<button type="submit" class="btn btn-sm btn-secondary">Show</button>
|
|
28
|
+
</form>
|
|
29
|
+
|
|
30
|
+
<% if metric_key -%>
|
|
31
|
+
<% if metric_days.empty? -%>
|
|
32
|
+
<p class="text-gray-400 text-xs py-2">No values recorded for this key in the filtered rows.</p>
|
|
33
|
+
<% else -%>
|
|
34
|
+
<table class="w-full text-xs">
|
|
35
|
+
<tr class="border-b border-gray-100">
|
|
36
|
+
<% %w[Day Count Sum Avg Min Max].each_with_index do |label, i| -%>
|
|
37
|
+
<th class="py-1 text-[10px] font-medium text-gray-400 uppercase tracking-wider <%= i.zero? ? "text-left" : "text-right" %>"><%= label %></th>
|
|
38
|
+
<% end -%>
|
|
39
|
+
</tr>
|
|
40
|
+
<% metric_days.each do |date, m| -%>
|
|
41
|
+
<% avg = (m[:count] > 0) ? (m[:sum] / m[:count]).round(2) : nil -%>
|
|
42
|
+
<tr class="border-b border-gray-100 last:border-0" data-metric-day="<%= view.h(date) %>">
|
|
43
|
+
<td class="py-1 text-gray-700 tabular-nums"><%= view.h(date) %></td>
|
|
44
|
+
<td class="py-1 text-right text-gray-500 tabular-nums" data-metric-count="<%= m[:count] %>"><%= m[:count] %></td>
|
|
45
|
+
<td class="py-1 text-right text-gray-700 tabular-nums font-medium" data-metric-sum="<%= m[:sum].round(2) %>"><%= m[:sum].round(2) %></td>
|
|
46
|
+
<td class="py-1 text-right text-gray-500 tabular-nums" data-metric-avg="<%= avg %>"><%= avg || "—" %></td>
|
|
47
|
+
<td class="py-1 text-right text-gray-500 tabular-nums" data-metric-min="<%= m[:min]&.round(2) %>"><%= m[:min] ? m[:min].round(2) : "—" %></td>
|
|
48
|
+
<td class="py-1 text-right text-gray-500 tabular-nums" data-metric-max="<%= m[:max]&.round(2) %>"><%= m[:max] ? m[:max].round(2) : "—" %></td>
|
|
49
|
+
</tr>
|
|
50
|
+
<% end -%>
|
|
51
|
+
</table>
|
|
52
|
+
<% skipped = metric_days.sum { |_date, m| m[:non_numeric] } -%>
|
|
53
|
+
<% if skipped > 0 -%>
|
|
54
|
+
<p class="text-[10px] text-gray-400 mt-2">
|
|
55
|
+
Skipped <%= skipped %> non-numeric value<%= skipped == 1 ? "" : "s" %> for this key.
|
|
56
|
+
</p>
|
|
57
|
+
<% end -%>
|
|
58
|
+
<% end -%>
|
|
59
|
+
<% end -%>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
<% end -%>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<% meta = s[:metadata] || {} -%>
|
|
2
|
+
<tr>
|
|
3
|
+
<% if selectable -%>
|
|
4
|
+
<td class="w-8 text-center"><input type="checkbox" name="session_ids[]" value="<%= view.h(s[:session_id]) %>" class="checkbox session-checkbox"></td>
|
|
5
|
+
<% end -%>
|
|
6
|
+
<td>
|
|
7
|
+
<% if s[:window_ids] && !s[:window_ids].empty? -%>
|
|
8
|
+
<a href="<%= view.h(view.base_path) %>/sessions/<%= view.h(s[:session_id]) %>" class="session-id font-medium text-blue-600 hover:text-blue-800">
|
|
9
|
+
<%= view.h(s[:session_id][0, 8]) %>
|
|
10
|
+
</a>
|
|
11
|
+
<% else -%>
|
|
12
|
+
<span class="session-id"><%= view.h(s[:session_id][0, 8]) %></span>
|
|
13
|
+
<% end -%>
|
|
14
|
+
<% if meta["has_errors"] -%>
|
|
15
|
+
<span class="badge badge-danger ml-1">errors</span>
|
|
16
|
+
<% end -%>
|
|
17
|
+
</td>
|
|
18
|
+
<td class="metadata-url text-gray-400" title="<%= view.h(meta["url"].to_s) %>">
|
|
19
|
+
<% if meta["url"] -%>
|
|
20
|
+
<% path = begin; URI.parse(meta["url"]).path; rescue; meta["url"]; end -%>
|
|
21
|
+
<%= view.h(path.to_s) %>
|
|
22
|
+
<% end -%>
|
|
23
|
+
</td>
|
|
24
|
+
<td>
|
|
25
|
+
<% if meta["userAgent"] -%>
|
|
26
|
+
<span class="text-gray-500" title="<%= view.h(meta["userAgent"]) %>"><%= view.h(view.parse_browser(meta["userAgent"]).to_s) %></span>
|
|
27
|
+
<% device = view.parse_device(meta["userAgent"]) -%>
|
|
28
|
+
<% if device == "Mobile" -%>
|
|
29
|
+
<span class="badge badge-warning ml-1">Mobile</span>
|
|
30
|
+
<% end -%>
|
|
31
|
+
<% end -%>
|
|
32
|
+
</td>
|
|
33
|
+
<td class="text-gray-500 tabular-nums"><%= s[:window_ids]&.size || 0 %></td>
|
|
34
|
+
<td class="text-gray-500 tabular-nums"><%= s[:event_count] || 0 %></td>
|
|
35
|
+
<td class="text-gray-500"><%= view.h(view.format_duration(s[:first_event_at], s[:last_event_at])) %></td>
|
|
36
|
+
<td class="text-gray-400 text-[10px]"><%= s[:updated_at] ? Time.at(s[:updated_at]).utc.strftime("%b %d, %H:%M") : "N/A" %></td>
|
|
37
|
+
<% if selectable -%>
|
|
38
|
+
<td class="text-right">
|
|
39
|
+
<button type="button" class="btn btn-xs btn-ghost-danger" data-action="delete-session" data-session-id="<%= view.h(s[:session_id]) %>" data-csrf-token="<%= view.h(csrf_token) %>" data-base-path="<%= view.h(view.base_path) %>">Delete</button>
|
|
40
|
+
</td>
|
|
41
|
+
<% end -%>
|
|
42
|
+
</tr>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<% if sibling && sibling[:count] > 0 -%>
|
|
2
|
+
<span class="block mt-1">
|
|
3
|
+
<a href="<%= view.h(href) %>" class="text-blue-600 hover:text-blue-800"
|
|
4
|
+
data-sibling-count="<%= sibling[:count] %>"><%= sibling[:count] %><%= sibling[:capped] ? "+" : "" %> <%= view.h((sibling[:count] == 1 && !sibling[:capped]) ? noun : "#{noun}s") %> →</a>
|
|
5
|
+
</span>
|
|
6
|
+
<% end -%>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<nav class="tabs" aria-label="Section tabs">
|
|
2
|
+
<% tabs.each do |t| -%>
|
|
3
|
+
<a href="<%= view.h(t[:href]) %>"
|
|
4
|
+
class="tab <%= t[:active] ? "tab-active" : "" %>"
|
|
5
|
+
<%= t[:active] ? 'aria-current="page"' : "" %>>
|
|
6
|
+
<%= view.h(t[:label]) %>
|
|
7
|
+
<% if t[:count] -%><span class="tab-count"><%= t[:count] %></span><% end -%>
|
|
8
|
+
</a>
|
|
9
|
+
<% end -%>
|
|
10
|
+
</nav>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%# Warning shown when an analytics session scan was cut short by the
|
|
2
|
+
analytics_max_scan_sessions cap (or an accumulation bound), so the figures
|
|
3
|
+
on the page may be incomplete. `noun` names what may be missing (default
|
|
4
|
+
"data"); pass `scanned` to report the exact number of sessions scanned. %>
|
|
5
|
+
<% if was_truncated -%>
|
|
6
|
+
<% noun = (defined?(noun) && noun) ? noun : "data" -%>
|
|
7
|
+
<% scanned = (defined?(scanned) && scanned) ? scanned : nil -%>
|
|
8
|
+
<div class="banner-warning">
|
|
9
|
+
<% if scanned -%>
|
|
10
|
+
<span><strong>Results are incomplete.</strong> Only the first <%= scanned %>
|
|
11
|
+
sessions were scanned (the <code class="font-mono">analytics_max_scan_sessions</code>
|
|
12
|
+
cap), so some <%= view.h(noun) %> may not be reflected below.</span>
|
|
13
|
+
<% else -%>
|
|
14
|
+
<span><strong>Results may be incomplete.</strong> The session scan hit the
|
|
15
|
+
<code class="font-mono">analytics_max_scan_sessions</code> cap or an
|
|
16
|
+
accumulation bound, so some <%= view.h(noun) %> may not be reflected below.</span>
|
|
17
|
+
<% end -%>
|
|
18
|
+
</div>
|
|
19
|
+
<% end -%>
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<%= view.render_partial("_analytics_nav.html.erb", active: :conversions, since: view.since, until_str: view.until_str) %>
|
|
2
|
+
<div class="flex items-center justify-between mb-5">
|
|
3
|
+
<h1 class="page-title mb-0">Conversions</h1>
|
|
4
|
+
|
|
5
|
+
<form method="get" action="<%= view.h(view.base_path) %>/analytics/conversions" class="flex items-end gap-2">
|
|
6
|
+
<div class="shrink-0">
|
|
7
|
+
<label for="tag" class="label">Conversion event</label>
|
|
8
|
+
<select name="tag" id="tag" class="select" style="width:12rem">
|
|
9
|
+
<option value="">—</option>
|
|
10
|
+
<% view.tags.each do |tag| -%>
|
|
11
|
+
<option value="<%= view.h(tag) %>" <%= (view.selected_tag == tag) ? "selected" : "" %>><%= view.h(tag) %></option>
|
|
12
|
+
<% end -%>
|
|
13
|
+
</select>
|
|
14
|
+
</div>
|
|
15
|
+
<%= view.render_partial("_date_range.html.erb", since: view.since, until_str: view.until_str) %>
|
|
16
|
+
<button type="submit" class="btn btn-sm btn-secondary">Apply</button>
|
|
17
|
+
</form>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<%= view.render_partial("_truncation_warning.html.erb", was_truncated: view.was_truncated, noun: "sessions") %>
|
|
21
|
+
|
|
22
|
+
<% if view.selected_tag.nil? -%>
|
|
23
|
+
<div class="card">
|
|
24
|
+
<div class="text-center py-10 px-6 text-gray-400 text-xs">
|
|
25
|
+
Pick a conversion event tag to see how each acquisition source converts.
|
|
26
|
+
Tags are the custom-event tags your site records via
|
|
27
|
+
<code class="font-mono">Sentiero.track(tag)</code>; a session counts as a
|
|
28
|
+
conversion when that tag fires at least once anywhere in the session.
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<% else -%>
|
|
32
|
+
<% view.facets.each do |title, dimension, rows, kind| -%>
|
|
33
|
+
<div class="card mb-3">
|
|
34
|
+
<div class="card-body">
|
|
35
|
+
<div class="flex items-center justify-between mb-2">
|
|
36
|
+
<span class="text-sm font-medium text-gray-900"><%= view.h(title) %></span>
|
|
37
|
+
<% if kind == :utm -%>
|
|
38
|
+
<span class="text-[10px] text-gray-400">only campaign-tagged sessions appear; untagged traffic is omitted</span>
|
|
39
|
+
<% end -%>
|
|
40
|
+
</div>
|
|
41
|
+
<% if rows.empty? -%>
|
|
42
|
+
<div class="text-center py-6 px-6 text-gray-400 text-xs">
|
|
43
|
+
No <%= view.h(dimension) %> data for this tag in range.
|
|
44
|
+
</div>
|
|
45
|
+
<% else -%>
|
|
46
|
+
<table class="w-full text-xs">
|
|
47
|
+
<thead>
|
|
48
|
+
<tr class="text-[10px] font-medium text-gray-400 uppercase tracking-wider text-left">
|
|
49
|
+
<th class="py-1"><%= view.h(dimension) %></th>
|
|
50
|
+
<th class="py-1 text-right">Sessions</th>
|
|
51
|
+
<th class="py-1 text-right">Conversions</th>
|
|
52
|
+
<th class="py-1 text-right">Rate</th>
|
|
53
|
+
<th class="py-1 text-right">Examples</th>
|
|
54
|
+
</tr>
|
|
55
|
+
</thead>
|
|
56
|
+
<tbody>
|
|
57
|
+
<% rows.each do |row| -%>
|
|
58
|
+
<tr data-conv-key="<%= view.h(row[:key].to_s) %>"
|
|
59
|
+
data-conv-sessions="<%= row[:sessions] %>"
|
|
60
|
+
data-conv-conversions="<%= row[:conversions] %>"
|
|
61
|
+
data-conv-rate="<%= row[:conversion_rate] %>">
|
|
62
|
+
<td class="py-1 font-mono break-all text-gray-700"><%= view.h(row[:key].to_s) %></td>
|
|
63
|
+
<td class="py-1 text-right tabular-nums"><%= row[:sessions] %></td>
|
|
64
|
+
<td class="py-1 text-right tabular-nums"><%= row[:conversions] %></td>
|
|
65
|
+
<td class="py-1 text-right tabular-nums">
|
|
66
|
+
<% if row[:conversion_rate].nil? -%>
|
|
67
|
+
<span class="text-gray-400">—</span>
|
|
68
|
+
<% elsif row[:low_volume] -%>
|
|
69
|
+
<span class="text-gray-400"><%= row[:conversion_rate] %>% (low volume)</span>
|
|
70
|
+
<% else -%>
|
|
71
|
+
<%= row[:conversion_rate] %>%
|
|
72
|
+
<% end -%>
|
|
73
|
+
</td>
|
|
74
|
+
<td class="py-1 text-right whitespace-nowrap">
|
|
75
|
+
<% if row[:converting_example] -%>
|
|
76
|
+
<a class="text-blue-600 hover:text-blue-800" href="<%= view.player_link(row[:converting_example]) %>">converting →</a>
|
|
77
|
+
<% end -%>
|
|
78
|
+
<% if row[:non_converting_example] -%>
|
|
79
|
+
<a class="text-blue-600 hover:text-blue-800 ml-2" href="<%= view.player_link(row[:non_converting_example]) %>">non-converting →</a>
|
|
80
|
+
<% end -%>
|
|
81
|
+
</td>
|
|
82
|
+
</tr>
|
|
83
|
+
<% end -%>
|
|
84
|
+
</tbody>
|
|
85
|
+
</table>
|
|
86
|
+
<% end -%>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<% end -%>
|
|
90
|
+
<% end -%>
|
|
91
|
+
|
|
92
|
+
<div class="mt-4">
|
|
93
|
+
<a href="<%= view.h(view.base_path) %>/analytics" class="btn btn-sm btn-secondary">← Back to Analytics</a>
|
|
94
|
+
</div>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<%= view.render_partial("_analytics_nav.html.erb", active: :engagement, since: view.since, until_str: view.until_str) %>
|
|
2
|
+
<div class="flex items-center justify-between mb-5">
|
|
3
|
+
<h1 class="page-title mb-0">Engagement Scoring</h1>
|
|
4
|
+
|
|
5
|
+
<form method="get" action="<%= view.h(view.base_path) %>/analytics/engagement" class="flex items-end gap-2">
|
|
6
|
+
<%= view.render_partial("_date_range.html.erb", since: view.since, until_str: view.until_str) %>
|
|
7
|
+
<input type="hidden" name="sort" value="<%= view.h(view.sort) %>">
|
|
8
|
+
<button type="submit" class="btn btn-sm btn-secondary">Apply</button>
|
|
9
|
+
</form>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<p class="text-xs text-gray-500 mb-3">
|
|
13
|
+
This is a per-session <strong>struggle score (higher = more friction)</strong>
|
|
14
|
+
— a heuristic, not a verdict. It blends eight behavioural signals by fixed
|
|
15
|
+
weights: <strong>rage clicks</strong> (20%), <strong>dead clicks</strong> (15%),
|
|
16
|
+
<strong>nav churn</strong> (15%), <strong>idle ratio</strong> (10%),
|
|
17
|
+
<strong>thrashing scroll</strong> (10%), <strong>quick bounce</strong> (10%),
|
|
18
|
+
<strong>form refills</strong> (10%), and <strong>error abandonment</strong> (10%).
|
|
19
|
+
</p>
|
|
20
|
+
<p class="text-[10px] text-gray-400 mb-4">
|
|
21
|
+
Dead-click counts here are raw (pre-refinement) and may exceed the Frustration
|
|
22
|
+
page; a session can register both a dead click and error-abandonment for the
|
|
23
|
+
same crash.
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<%= view.render_partial("_truncation_warning.html.erb", was_truncated: view.was_truncated, noun: "sessions") %>
|
|
27
|
+
|
|
28
|
+
<div class="card mb-4">
|
|
29
|
+
<div class="card-body">
|
|
30
|
+
<h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-2">Score Distribution</h6>
|
|
31
|
+
<svg viewBox="0 0 <%= view.svg_width %> <%= view.svg_height %>" width="100%" style="max-width:<%= view.svg_width %>px" role="img" aria-label="Struggle score distribution across sessions">
|
|
32
|
+
<line x1="0" y1="<%= view.axis_y %>" x2="<%= view.svg_width %>" y2="<%= view.axis_y %>" stroke="#e5e7eb" stroke-width="1" />
|
|
33
|
+
<% view.distribution.each_with_index do |(label, count), i| -%>
|
|
34
|
+
<rect x="<%= view.bar_x(i) %>" y="<%= view.bar_y(count) %>" width="<%= view.bar_width %>" height="<%= view.bar_h(count) %>" fill="#2563eb" rx="2" />
|
|
35
|
+
<text x="<%= view.bar_x(i) + view.bar_width / 2 %>" y="<%= (view.bar_y(count) - 4).round(1) %>" text-anchor="middle" font-size="10" fill="#374151"><%= count %></text>
|
|
36
|
+
<text x="<%= view.bar_x(i) + view.bar_width / 2 %>" y="<%= view.axis_y + 14 %>" text-anchor="middle" font-size="9" fill="#6b7280"><%= view.h(label) %></text>
|
|
37
|
+
<% end -%>
|
|
38
|
+
</svg>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<% if view.sorted_sessions.empty? -%>
|
|
43
|
+
<div class="card">
|
|
44
|
+
<div class="text-center py-10 px-6 text-gray-400 text-xs">
|
|
45
|
+
No sessions scored yet. Once sessions are recorded, the highest-struggle
|
|
46
|
+
ones rank here.
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<% else -%>
|
|
50
|
+
<div class="table-wrap mb-4">
|
|
51
|
+
<table class="data-table">
|
|
52
|
+
<thead>
|
|
53
|
+
<tr>
|
|
54
|
+
<th>
|
|
55
|
+
<a class="<%= view.sort == "score" ? "font-semibold text-gray-900" : "text-blue-600 hover:text-blue-800" %>"
|
|
56
|
+
href="<%= view.h(view.sort_link("score")) %>">Score<%= view.sort == "score" ? " ↓" : "" %></a>
|
|
57
|
+
</th>
|
|
58
|
+
<th>Page</th>
|
|
59
|
+
<th class="text-right">
|
|
60
|
+
<a class="<%= view.sort == "duration" ? "font-semibold text-gray-900" : "text-blue-600 hover:text-blue-800" %>"
|
|
61
|
+
href="<%= view.h(view.sort_link("duration")) %>">Duration<%= view.sort == "duration" ? " ↓" : "" %></a>
|
|
62
|
+
</th>
|
|
63
|
+
<th>Signals</th>
|
|
64
|
+
<th class="text-right">Replay</th>
|
|
65
|
+
</tr>
|
|
66
|
+
</thead>
|
|
67
|
+
<tbody>
|
|
68
|
+
<% view.sorted_sessions.each do |row| -%>
|
|
69
|
+
<tr>
|
|
70
|
+
<td class="tabular-nums"><span class="<%= view.badge_class(row[:score]) %>"><%= row[:score] %></span></td>
|
|
71
|
+
<td class="break-words max-w-xs"><%= row[:url] ? view.h(row[:url]) : "—" %></td>
|
|
72
|
+
<td class="text-right tabular-nums"><%= (row[:duration_ms] / 1000.0).round(1) %>s</td>
|
|
73
|
+
<td>
|
|
74
|
+
<% if view.chips(row[:signals]).empty? -%>
|
|
75
|
+
<span class="text-gray-400 text-xs">—</span>
|
|
76
|
+
<% else -%>
|
|
77
|
+
<% view.chips(row[:signals]).each do |chip| -%>
|
|
78
|
+
<span class="badge badge-neutral mr-1 mb-1"><%= chip %></span>
|
|
79
|
+
<% end -%>
|
|
80
|
+
<% end -%>
|
|
81
|
+
</td>
|
|
82
|
+
<td class="text-right">
|
|
83
|
+
<a class="text-blue-600 hover:text-blue-800"
|
|
84
|
+
href="<%= view.h(view.base_path) %>/sessions/<%= view.h(row[:session_id].to_s) %>/windows/<%= view.h(row[:window_id].to_s) %>?t=0">Open in player →</a>
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
<% end -%>
|
|
88
|
+
</tbody>
|
|
89
|
+
</table>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<% if view.scanned > view.sorted_sessions.size -%>
|
|
93
|
+
<p class="text-[10px] text-gray-400 mb-4">
|
|
94
|
+
Showing top <%= view.sorted_sessions.size %> of <%= view.scanned %> scanned sessions.
|
|
95
|
+
</p>
|
|
96
|
+
<% end -%>
|
|
97
|
+
<% end -%>
|
|
98
|
+
|
|
99
|
+
<div class="mt-4">
|
|
100
|
+
<a href="<%= view.h(view.base_path) %>/analytics" class="btn btn-sm btn-secondary">← Back to Analytics</a>
|
|
101
|
+
</div>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<%= view.render_partial("_analytics_nav.html.erb", active: :frustration, since: view.since, until_str: view.until_str) %>
|
|
2
|
+
<div class="flex items-center justify-between mb-5">
|
|
3
|
+
<h1 class="page-title mb-0">Frustration Signals</h1>
|
|
4
|
+
|
|
5
|
+
<form method="get" action="<%= view.h(view.base_path) %>/analytics/frustration" class="flex items-end gap-2">
|
|
6
|
+
<%= view.render_partial("_date_range.html.erb", since: view.since, until_str: view.until_str) %>
|
|
7
|
+
<button type="submit" class="btn btn-sm btn-secondary">Apply</button>
|
|
8
|
+
</form>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<%= view.render_partial("_truncation_warning.html.erb", was_truncated: view.was_truncated, noun: "incidents") %>
|
|
12
|
+
|
|
13
|
+
<% if view.sorted_pages.empty? -%>
|
|
14
|
+
<div class="card">
|
|
15
|
+
<div class="text-center py-10 px-6 text-gray-400 text-xs">
|
|
16
|
+
No frustration signals detected yet. Rage clicks are bursts of 3+ clicks
|
|
17
|
+
at the same spot within 500ms; dead clicks get no page response within 500ms.
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
<% else -%>
|
|
21
|
+
<p class="text-xs text-gray-500 mb-3">
|
|
22
|
+
<strong>Rage clicks</strong> are bursts of 3+ clicks at the same spot within
|
|
23
|
+
500ms; <strong>dead clicks</strong> are clicks the page never responds to
|
|
24
|
+
within 500ms. Element attribution needs the recorder’s
|
|
25
|
+
<code class="font-mono">capture_clicks</code> annotations.
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<div class="table-wrap mb-4">
|
|
29
|
+
<table class="data-table">
|
|
30
|
+
<thead>
|
|
31
|
+
<tr>
|
|
32
|
+
<th>Page</th>
|
|
33
|
+
<th class="text-right">Rage clicks</th>
|
|
34
|
+
<th class="text-right">Dead clicks</th>
|
|
35
|
+
<th class="text-right">Sessions affected</th>
|
|
36
|
+
</tr>
|
|
37
|
+
</thead>
|
|
38
|
+
<tbody>
|
|
39
|
+
<% view.sorted_pages.each do |url, page| -%>
|
|
40
|
+
<tr>
|
|
41
|
+
<td class="break-words max-w-xs"><%= view.h(url) %></td>
|
|
42
|
+
<td class="text-right tabular-nums">
|
|
43
|
+
<% if page[:rage_count] > 0 -%>
|
|
44
|
+
<span class="badge badge-danger"><%= page[:rage_count] %></span>
|
|
45
|
+
<% else -%>
|
|
46
|
+
<span class="text-gray-400">0</span>
|
|
47
|
+
<% end -%>
|
|
48
|
+
</td>
|
|
49
|
+
<td class="text-right tabular-nums">
|
|
50
|
+
<% if page[:dead_count] > 0 -%>
|
|
51
|
+
<span class="badge badge-warning"><%= page[:dead_count] %></span>
|
|
52
|
+
<% else -%>
|
|
53
|
+
<span class="text-gray-400">0</span>
|
|
54
|
+
<% end -%>
|
|
55
|
+
</td>
|
|
56
|
+
<td class="text-right tabular-nums"><%= page[:sessions_affected] %></td>
|
|
57
|
+
</tr>
|
|
58
|
+
<% end -%>
|
|
59
|
+
</tbody>
|
|
60
|
+
</table>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<% view.sorted_pages.each do |url, page| -%>
|
|
64
|
+
<div class="card mb-3">
|
|
65
|
+
<div class="card-body">
|
|
66
|
+
<div class="text-sm font-medium text-gray-900 break-words mb-3"><%= view.h(url) %></div>
|
|
67
|
+
|
|
68
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
69
|
+
<div>
|
|
70
|
+
<h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-2">Top Rage-Clicked Elements</h6>
|
|
71
|
+
<% if page[:top_selectors].empty? -%>
|
|
72
|
+
<p class="text-xs text-gray-400">No selector-attributed rage clicks.</p>
|
|
73
|
+
<% else -%>
|
|
74
|
+
<table class="w-full text-xs">
|
|
75
|
+
<tbody>
|
|
76
|
+
<% page[:top_selectors].each do |element| -%>
|
|
77
|
+
<tr>
|
|
78
|
+
<td class="py-1 font-mono break-all"><%= view.h(element[:selector]) %></td>
|
|
79
|
+
<td class="py-1 text-right tabular-nums text-gray-500"><%= element[:count] %></td>
|
|
80
|
+
</tr>
|
|
81
|
+
<% end -%>
|
|
82
|
+
</tbody>
|
|
83
|
+
</table>
|
|
84
|
+
<% end -%>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div>
|
|
88
|
+
<h6 class="text-xs font-semibold text-gray-800 uppercase tracking-wider mb-2">Incidents</h6>
|
|
89
|
+
<table class="w-full text-xs">
|
|
90
|
+
<tbody>
|
|
91
|
+
<% page[:incidents].each do |incident| -%>
|
|
92
|
+
<tr>
|
|
93
|
+
<td class="py-1">
|
|
94
|
+
<% if incident[:subtype] == "rage_click" -%>
|
|
95
|
+
<span class="badge badge-danger">rage ×<%= incident[:count].to_i %></span>
|
|
96
|
+
<% if incident[:selector] -%>
|
|
97
|
+
<span class="font-mono text-gray-500 ml-1"><%= view.h(incident[:selector]) %></span>
|
|
98
|
+
<% end -%>
|
|
99
|
+
<% elsif incident[:kind] == "error" -%>
|
|
100
|
+
<%# C4: the A4 analyzer tags dead clicks whose response
|
|
101
|
+
window contains a JS error custom — the page
|
|
102
|
+
crashed instead of responding. Older incident
|
|
103
|
+
shapes without :kind fall through to plain dead. -%>
|
|
104
|
+
<a href="<%= view.h(view.base_path) %>/issues?source=client"
|
|
105
|
+
class="badge badge-danger" data-incident-kind="error"
|
|
106
|
+
title="A JS error fired within this click's response window — open client JS errors">dead + JS error</a>
|
|
107
|
+
<% else -%>
|
|
108
|
+
<span class="badge badge-warning">dead</span>
|
|
109
|
+
<% end -%>
|
|
110
|
+
</td>
|
|
111
|
+
<td class="py-1 text-gray-400 tabular-nums">+<%= (incident[:offset_ms].to_f / 1000).round(1) %>s</td>
|
|
112
|
+
<td class="py-1 text-right">
|
|
113
|
+
<a class="text-blue-600 hover:text-blue-800"
|
|
114
|
+
href="<%= view.h(view.base_path) %>/sessions/<%= view.h(incident[:session_id].to_s) %>/windows/<%= view.h(incident[:window_id].to_s) %>?t=<%= incident[:offset_ms].to_i %>">Open in player →</a>
|
|
115
|
+
</td>
|
|
116
|
+
</tr>
|
|
117
|
+
<% end -%>
|
|
118
|
+
</tbody>
|
|
119
|
+
</table>
|
|
120
|
+
<% if page[:rage_count] + page[:dead_count] > page[:incidents].size -%>
|
|
121
|
+
<p class="text-[10px] text-gray-400 mt-2">
|
|
122
|
+
Showing the first <%= page[:incidents].size %> of
|
|
123
|
+
<%= page[:rage_count] + page[:dead_count] %> incidents.
|
|
124
|
+
</p>
|
|
125
|
+
<% end -%>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<% end -%>
|
|
131
|
+
<% end -%>
|
|
132
|
+
|
|
133
|
+
<div class="mt-4">
|
|
134
|
+
<a href="<%= view.h(view.base_path) %>/analytics" class="btn btn-sm btn-secondary">← Back to Analytics</a>
|
|
135
|
+
</div>
|