spectator_sport 0.3.0 → 0.4.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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -6
  3. data/app/controllers/spectator_sport/dashboard/dashboards_controller.rb +1 -0
  4. data/app/controllers/spectator_sport/dashboard/recordings_controller.rb +50 -0
  5. data/app/controllers/spectator_sport/dashboard/session_windows_controller.rb +9 -1
  6. data/app/controllers/spectator_sport/dashboard/tags_controller.rb +8 -5
  7. data/app/controllers/spectator_sport/events_controller.rb +64 -8
  8. data/app/frontend/spectator_sport/dashboard/modules/player_controller.js +4 -0
  9. data/app/frontend/spectator_sport/dashboard/vendor/rrweb-player/rrweb-player.min.css +1 -1
  10. data/app/frontend/spectator_sport/dashboard/vendor/rrweb-player/rrweb-player.min.js +7 -10
  11. data/app/helpers/spectator_sport/script_helper.rb +11 -2
  12. data/app/models/spectator_sport/event.rb +3 -2
  13. data/app/models/spectator_sport/label.rb +58 -0
  14. data/app/models/spectator_sport/recording.rb +17 -0
  15. data/app/models/spectator_sport/recording_analysis.rb +11 -0
  16. data/app/models/spectator_sport/search_query/lexer.rb +128 -0
  17. data/app/models/spectator_sport/search_query/parser.rb +131 -0
  18. data/app/models/spectator_sport/search_query/syntax_tree.rb +152 -0
  19. data/app/models/spectator_sport/search_query.rb +97 -0
  20. data/app/models/spectator_sport/session_window.rb +3 -2
  21. data/app/views/spectator_sport/dashboard/dashboards/index.html.erb +1 -48
  22. data/app/views/spectator_sport/dashboard/recordings/_stream_events_frame.erb +1 -0
  23. data/app/views/spectator_sport/dashboard/recordings/details.html.erb +22 -0
  24. data/app/views/spectator_sport/dashboard/recordings/index.html.erb +24 -0
  25. data/app/views/spectator_sport/dashboard/recordings/show.html.erb +69 -0
  26. data/app/views/spectator_sport/dashboard/recordings/stream_events.erb +13 -0
  27. data/app/views/spectator_sport/dashboard/session_windows/index.html.erb +7 -0
  28. data/app/views/spectator_sport/dashboard/session_windows/show.html.erb +54 -20
  29. data/app/views/spectator_sport/dashboard/shared/_recordings.html.erb +47 -0
  30. data/app/views/spectator_sport/dashboard/shared/_session_windows.html.erb +48 -0
  31. data/app/views/spectator_sport/dashboard/tags/show.html.erb +1 -23
  32. data/app/views/spectator_sport/events/index.js +281 -121
  33. data/app/views/spectator_sport/shared/_navbar.erb +6 -1
  34. data/config/dashboard_routes.rb +8 -2
  35. data/lib/spectator_sport/engine.rb +7 -3
  36. data/lib/spectator_sport/version.rb +1 -1
  37. data/lib/spectator_sport.rb +5 -1
  38. metadata +17 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce7e9837080e5b1f96455b0f135fda0aff65832e57ef01c9cc42763be9579a46
4
- data.tar.gz: be3679d719d26e852867510b223983e763de348c5c3c44bb58592e4960ae7fd2
3
+ metadata.gz: ae07042f4d65dd617066efe9e6ac3f90f6a8726dec741b9e3965c6d76eef1648
4
+ data.tar.gz: 7c2fc362efbea72b71dc61b99568577d554e47f5b4a63e77298af5e5879355c5
5
5
  SHA512:
6
- metadata.gz: 3c076b0fd70e3667bf2d8b19f939995589332ee853d63dd30d0281248416df8788f627b7d5ca15bd534bdaf63d4fca377befe8a58941c69769c3d38dbd1b42d3
7
- data.tar.gz: e8e8901c34d153bbe77a4718bc9da962d89d91e52d6f618be9d524a1bb045f2f907245a9b89888c5d4f9b7602268cf58089666aa2cbe0bb3db5b7f2d72e17de0
6
+ metadata.gz: 35962025a9098a0d9fe0a1e113ee8c9036a4c24f6cd7f1fa7fddb00c8fc923eba9102f54fbfe56e2f4080704dacc27762d18e59845e6257ff4a4e3decbb18b97
7
+ data.tar.gz: 2eb708af1cf7e0e59ec60aa82b5940846869e36d91834b9a63e3d59b8d8b20f180fc030576deb34e3d4fb91fc9b81baca46e582a89acf45e28d5eadebe5bfcab
data/README.md CHANGED
@@ -38,7 +38,7 @@ To install Spectator Sport in your Rails application:
38
38
  bundle add spectator_sport
39
39
  ```
40
40
  2. Install Spectator Sport in your application. _🚧 This will change on the path to v1._ Explore the `/demo` app as live example:
41
- - Create database migrations with `bin/rails g spectator_sport:install:migrations`. Apply migrations with `bin/rails db:prepare`
41
+ - Create database migrations with `bin/rails spectator_sport:install:migrations`. Apply migrations with `bin/rails db:prepare`
42
42
  - Mount the recorder API in your application's routes with `mount SpectatorSport::Engine, at: "/spectator_sport, as: :spectator_sport"`
43
43
  - Add the `spectator_sport_script_tags` helper to the bottom of the `<head>` of `layout/application.rb`. Example:
44
44
  ```erb
@@ -57,17 +57,25 @@ To install Spectator Sport in your Rails application:
57
57
  ```
58
58
  3. To view recordings, you will want to mount the Player Dashboard in your application and set up authorization to limit access. See the section on [Dashboard authorization](#dashboard-authorization) for instructions.
59
59
 
60
- ## Tagging recordings
60
+ ## Labeling recordings
61
61
 
62
- You can associate a string tag (e.g. a user ID or account identifier) with the current recording by calling `spectator_sport_tag_recording` in any template:
62
+ You can associate key-value labels with the current recording by calling `spectator_sport_label_recording` in any template:
63
63
 
64
64
  ```erb
65
- <%= spectator_sport_tag_recording(current_user.id.to_s) %>
65
+ <%= spectator_sport_label_recording(current_user.id.to_s, key: "user_id", strategy: :one) %>
66
+ <%= spectator_sport_label_recording("admin", key: "role", strategy: :many) %>
67
+ <%= spectator_sport_label_recording("vip") %>
66
68
  ```
67
69
 
68
- This renders a hidden `<meta>` element signed by the server. The recording client detects it and immediately sends it to the API, where the signature is verified before the tag is stored. Tags are displayed in the dashboard and can be used to look up all recordings associated with a given value.
70
+ The `key` argument is optional. When omitted, the label behaves like a tag. The `strategy` argument (default `:many`) controls how values are stored per recording:
69
71
 
70
- **Note:** this requires the `spectator_sport_session_window_tags` migration to be applied (`bin/rails spectator_sport:install:migrations && bin/rails db:migrate`). If the migration hasn't been run, the feature is silently disabled.
72
+ - `strategy: :many` (default): multiple values for the same key are accumulated per recording.
73
+ - `strategy: :one`: only one value per key is kept per recording. A later call with the same key replaces the stored value. Without a key, behaves like `:many`.
74
+ - `strategy: :first`: only the first value for a key is stored; subsequent calls with the same key are ignored. Requires a key.
75
+
76
+ This renders a hidden `<meta>` element signed by the server. The recording client detects it and immediately sends it to the API, where the signature is verified before the label is stored. Labels are displayed in the dashboard and can be used to look up all recordings associated with a given key-value pair.
77
+
78
+ **Note:** this requires the `spectator_sport_labels` migration to be applied (`bin/rails spectator_sport:install:migrations && bin/rails db:migrate`). If the migration hasn't been run, the feature is silently disabled.
71
79
 
72
80
  ## Stopping recording
73
81
 
@@ -4,6 +4,7 @@ module SpectatorSport
4
4
  def index
5
5
  @session_windows = SessionWindow.order(:created_at).limit(50).reverse_order
6
6
  @session_windows = @session_windows.includes(:session_window_tags) if SpectatorSport::SessionWindowTag.migrated?
7
+ @session_windows = @session_windows.includes(:labels) if SpectatorSport::Label.migrated?
7
8
  end
8
9
  end
9
10
  end
@@ -0,0 +1,50 @@
1
+ module SpectatorSport
2
+ module Dashboard
3
+ class RecordingsController < ApplicationController
4
+ def index
5
+ @search_query = SpectatorSport::SearchQuery.new(query: params[:query])
6
+
7
+ @recordings = Recording.order(:created_at).limit(50).reverse_order
8
+ @recordings = @recordings.includes(:labels) if SpectatorSport::Label.migrated?
9
+
10
+ if @search_query.query.present? && SpectatorSport::Label.migrated?
11
+ if @search_query.valid?
12
+ @recordings = @search_query.to_scope(base_scope: @recordings)
13
+ else
14
+ @recordings = @recordings.none
15
+ end
16
+ end
17
+ end
18
+
19
+ def show
20
+ @recording = Recording.find(params[:id])
21
+ @events = @recording.events.page_after(nil)
22
+ @labels = SpectatorSport::Label.migrated? ? @recording.labels.to_a : []
23
+ end
24
+
25
+ def stream_events
26
+ response.content_type = "text/vnd.turbo-stream.html"
27
+ return head(:ok) if params[:after_event_id].blank?
28
+
29
+ recording = Recording.find(params[:id])
30
+ previous_event = recording.events.find_by(id: params[:after_event_id])
31
+ @events = recording.events.page_after(previous_event)
32
+
33
+ render layout: false
34
+ end
35
+
36
+ def details
37
+ @recording = Recording.find(params[:id])
38
+ end
39
+
40
+ def destroy
41
+ @recording = Recording.find(params[:id])
42
+ @recording.events.delete_all
43
+ @recording.labels.delete_all if SpectatorSport::Label.migrated?
44
+ @recording.delete
45
+
46
+ redirect_to root_path
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,10 +1,16 @@
1
1
  module SpectatorSport
2
2
  module Dashboard
3
3
  class SessionWindowsController < ApplicationController
4
+ def index
5
+ @session_windows = SessionWindow.order(:created_at).limit(50).reverse_order
6
+ @session_windows = @session_windows.includes(:session_window_tags) if SpectatorSport::SessionWindowTag.migrated?
7
+ @session_windows = @session_windows.includes(:labels) if SpectatorSport::Label.migrated?
8
+ end
9
+
4
10
  def show
5
11
  @session_window = SessionWindow.find(params[:id])
6
12
  @events = @session_window.events.page_after(nil)
7
- @tags = SpectatorSport::SessionWindowTag.migrated? ? @session_window.session_window_tags.to_a : []
13
+ @labels = SpectatorSport::Label.migrated? ? @session_window.labels.to_a : []
8
14
  end
9
15
 
10
16
  def events
@@ -25,6 +31,8 @@ module SpectatorSport
25
31
  def destroy
26
32
  @session_window = SessionWindow.find(params[:id])
27
33
  @session_window.events.delete_all
34
+ @session_window.session_window_tags.delete_all if SpectatorSport::SessionWindowTag.migrated?
35
+ @session_window.labels.delete_all if SpectatorSport::Label.migrated?
28
36
  @session_window.delete
29
37
 
30
38
  redirect_to root_path
@@ -3,12 +3,15 @@ module SpectatorSport
3
3
  class TagsController < ApplicationController
4
4
  def show
5
5
  @tag = params[:name]
6
- @session_windows = SpectatorSport::SessionWindowTag
7
- .where(tag: @tag)
8
- .includes(:session_window)
9
- .order(created_at: :desc)
6
+ scope = SessionWindow
7
+ .joins(:session_window_tags)
8
+ .where(spectator_sport_session_window_tags: { tag: @tag })
9
+ .distinct
10
+ .order(updated_at: :desc)
10
11
  .limit(50)
11
- .map(&:session_window)
12
+ scope = scope.includes(:session_window_tags) if SpectatorSport::SessionWindowTag.migrated?
13
+ scope = scope.includes(:labels) if SpectatorSport::Label.migrated?
14
+ @session_windows = scope
12
15
  end
13
16
  end
14
17
  end
@@ -6,19 +6,61 @@ module SpectatorSport
6
6
  end
7
7
 
8
8
  def create
9
- data = if params.key?(:sessionId) && params.key?(:windowId) && params.key?(:events)
10
- params.slice(:sessionId, :windowId, :events, :tags).stringify_keys
9
+ data = if params.key?(:events) && (params.key?(:recordingId) || params.key?(:windowId))
10
+ params.slice(:sessionId, :recordingId, :windowId, :events, :tags, :labels).stringify_keys
11
11
  else
12
12
  # beacon sends JSON in the request body
13
- JSON.parse(request.body.read).slice("sessionId", "windowId", "events", "tags")
13
+ JSON.parse(request.body.read).slice("sessionId", "recordingId", "windowId", "events", "tags", "labels")
14
14
  end
15
15
 
16
- session_secure_id = data["sessionId"]
17
- window_secure_id = data["windowId"]
16
+ # `windowId` is the legacy name for `recordingId`; accept either for backward compatibility.
17
+ data["recordingId"] ||= data["windowId"]
18
+ data["windowId"] ||= data["recordingId"]
19
+
18
20
  events = data["events"]
19
21
 
20
- session = Session.find_or_create_by(secure_id: session_secure_id)
21
- window = SessionWindow.find_or_create_by(secure_id: window_secure_id, session: session)
22
+ if SpectatorSport::Recording.migrated?
23
+ create_recording(data, events)
24
+ else
25
+ create_session_window(data, events)
26
+ end
27
+
28
+ render json: { message: "ok" }
29
+ end
30
+
31
+ private
32
+
33
+ def create_recording(data, events)
34
+ recording = Recording.find_or_create_by(secure_id: data["recordingId"])
35
+
36
+ records_data = events.map do |event|
37
+ { recording_id: recording.id, event_data: event, created_at: Time.at(event["timestamp"].to_f / 1000.0) }
38
+ end.to_a
39
+ Event.insert_all(records_data) if records_data.any?
40
+
41
+ last_event = records_data.max_by { |record| record[:created_at] }
42
+ recording.update(updated_at: last_event[:created_at]) if last_event
43
+
44
+ if SpectatorSport::Label.migrated?
45
+ verifier = Rails.application.message_verifier(:spectator_sport_label_recording)
46
+ Array(data["labels"]).first(20).each do |signed_label|
47
+ label_data = verifier.verified(signed_label)
48
+ next unless label_data.is_a?(Hash)
49
+ Label.record(
50
+ recording: recording,
51
+ value: label_data["value"],
52
+ key: label_data["key"],
53
+ strategy: label_data["strategy"]
54
+ )
55
+ rescue StandardError
56
+ nil
57
+ end
58
+ end
59
+ end
60
+
61
+ def create_session_window(data, events)
62
+ session = Session.find_or_create_by(secure_id: data["sessionId"])
63
+ window = SessionWindow.find_or_create_by(secure_id: data["windowId"], session: session)
22
64
 
23
65
  records_data = events.map do |event|
24
66
  { session_id: session.id, session_window_id: window.id, event_data: event, created_at: Time.at(event["timestamp"].to_f / 1000.0) }
@@ -39,7 +81,21 @@ module SpectatorSport
39
81
  end
40
82
  end
41
83
 
42
- render json: { message: "ok" }
84
+ if SpectatorSport::Label.migrated?
85
+ verifier = Rails.application.message_verifier(:spectator_sport_label_recording)
86
+ Array(data["labels"]).first(20).each do |signed_label|
87
+ label_data = verifier.verified(signed_label)
88
+ next unless label_data.is_a?(Hash)
89
+ Label.record(
90
+ session_window: window,
91
+ value: label_data["value"],
92
+ key: label_data["key"],
93
+ strategy: label_data["strategy"]
94
+ )
95
+ rescue StandardError
96
+ nil
97
+ end
98
+ end
43
99
  end
44
100
  end
45
101
  end
@@ -10,11 +10,15 @@ export default class extends Controller {
10
10
  static targets = [ "player", "events", "linkUrl" ]
11
11
 
12
12
  connect() {
13
+ if (this.eventsValue.length === 0) return;
14
+
13
15
  this.player = new Player({
14
16
  target: this.playerTarget,
15
17
  props: {
16
18
  events: this.eventsValue,
17
19
  liveMode: true,
20
+ skipInactive: false,
21
+ speedOption: [1, 2, 4, 8, 16],
18
22
  }
19
23
  });
20
24
 
@@ -1,2 +1,2 @@
1
- // https://cdn.jsdelivr.net/npm/rrweb-player@2.0.0-alpha.18/dist/style.min.css
2
1
  .switch.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{height:1em;display:flex;align-items:center}.switch.disabled.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{opacity:.5}.label.svelte-a6h7w7.svelte-a6h7w7.svelte-a6h7w7{margin:0 8px}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7.svelte-a6h7w7{position:absolute;opacity:0}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7{width:2em;height:1em;position:relative;cursor:pointer;display:block}.switch.disabled.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7{cursor:not-allowed}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7:before{content:"";position:absolute;width:2em;height:1em;left:.1em;transition:background .1s ease;background:#4950f680;border-radius:50px}.switch.svelte-a6h7w7 label.svelte-a6h7w7.svelte-a6h7w7:after{content:"";position:absolute;width:1em;height:1em;border-radius:50px;left:0;transition:all .2s ease;box-shadow:0 2px 5px #0000004d;background:#fcfff4;animation:switch-off .2s ease-out;z-index:2}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7:checked+label.svelte-a6h7w7:before{background:#4950f6}.switch.svelte-a6h7w7 input[type=checkbox].svelte-a6h7w7:checked+label.svelte-a6h7w7:after{animation:switch-on .2s ease-out;left:1.1em}.rr-controller.svelte-189zk2r.svelte-189zk2r{width:100%;height:80px;background:#fff;display:flex;flex-direction:column;justify-content:space-around;align-items:center;border-radius:0 0 5px 5px}.rr-timeline.svelte-189zk2r.svelte-189zk2r{width:80%;display:flex;align-items:center}.rr-timeline__time.svelte-189zk2r.svelte-189zk2r{display:inline-block;width:100px;text-align:center;color:#11103e}.rr-progress.svelte-189zk2r.svelte-189zk2r{flex:1;height:12px;background:#eee;position:relative;border-radius:3px;cursor:pointer;box-sizing:border-box;border-top:solid 4px #fff;border-bottom:solid 4px #fff}.rr-progress.disabled.svelte-189zk2r.svelte-189zk2r{cursor:not-allowed}.rr-progress__step.svelte-189zk2r.svelte-189zk2r{height:100%;position:absolute;left:0;top:0;background:#e0e1fe}.rr-progress__handler.svelte-189zk2r.svelte-189zk2r{width:20px;height:20px;border-radius:10px;position:absolute;top:2px;transform:translate(-50%,-50%);background:#4950f6}.rr-controller__btns.svelte-189zk2r.svelte-189zk2r{display:flex;align-items:center;justify-content:center;font-size:13px}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r{width:32px;height:32px;display:flex;padding:0;align-items:center;justify-content:center;background:none;border:none;border-radius:50%;cursor:pointer}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r:active{background:#e0e1fe}.rr-controller__btns.svelte-189zk2r button.active.svelte-189zk2r{color:#fff;background:#4950f6}.rr-controller__btns.svelte-189zk2r button.svelte-189zk2r:disabled{cursor:not-allowed}.replayer-wrapper{position:relative}.replayer-mouse{position:absolute;width:20px;height:20px;transition:left .05s linear,top .05s linear;background-size:contain;background-position:center center;background-repeat:no-repeat;background-image:url(data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9JzMwMHB4JyB3aWR0aD0nMzAwcHgnICBmaWxsPSIjMDAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGRhdGEtbmFtZT0iTGF5ZXIgMSIgdmlld0JveD0iMCAwIDUwIDUwIiB4PSIwcHgiIHk9IjBweCI+PHRpdGxlPkRlc2lnbl90bnA8L3RpdGxlPjxwYXRoIGQ9Ik00OC43MSw0Mi45MUwzNC4wOCwyOC4yOSw0NC4zMywxOEExLDEsMCwwLDAsNDQsMTYuMzlMMi4zNSwxLjA2QTEsMSwwLDAsMCwxLjA2LDIuMzVMMTYuMzksNDRhMSwxLDAsMCwwLDEuNjUuMzZMMjguMjksMzQuMDgsNDIuOTEsNDguNzFhMSwxLDAsMCwwLDEuNDEsMGw0LjM4LTQuMzhBMSwxLDAsMCwwLDQ4LjcxLDQyLjkxWm0tNS4wOSwzLjY3TDI5LDMyYTEsMSwwLDAsMC0xLjQxLDBsLTkuODUsOS44NUwzLjY5LDMuNjlsMzguMTIsMTRMMzIsMjcuNThBMSwxLDAsMCwwLDMyLDI5TDQ2LjU5LDQzLjYyWiI+PC9wYXRoPjwvc3ZnPg==);border-color:transparent}.replayer-mouse:after{content:"";display:inline-block;width:20px;height:20px;background:#4950f6;border-radius:100%;transform:translate(-50%,-50%);opacity:.3}.replayer-mouse.active:after{animation:click .2s ease-in-out 1}.replayer-mouse.touch-device{background-image:none;width:70px;height:70px;border-width:4px;border-style:solid;border-radius:100%;margin-left:-37px;margin-top:-37px;border-color:#4950f600;transition:left 0s linear,top 0s linear,border-color .2s ease-in-out}.replayer-mouse.touch-device.touch-active{border-color:#4950f6;transition:left .25s linear,top .25s linear,border-color .2s ease-in-out}.replayer-mouse.touch-device:after{opacity:0}.replayer-mouse.touch-device.active:after{animation:touch-click .2s ease-in-out 1}.replayer-mouse-tail{position:absolute;pointer-events:none}@keyframes click{0%{opacity:.3;width:20px;height:20px}50%{opacity:.5;width:10px;height:10px}}@keyframes touch-click{0%{opacity:0;width:20px;height:20px}50%{opacity:.5;width:10px;height:10px}}.rr-player{position:relative;background:#fff;float:left;border-radius:5px;box-shadow:0 24px 48px #11103e1f}.rr-player__frame{overflow:hidden}.replayer-wrapper{float:left;clear:both;transform-origin:top left;left:50%;top:50%}.replayer-wrapper>iframe{border:none}
2
+ /*# sourceMappingURL=style.min.css.map */