acta 0.2.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 (49) hide show
  1. checksums.yaml +7 -0
  2. data/.tool-versions +1 -0
  3. data/CHANGELOG.md +210 -0
  4. data/LICENSE +21 -0
  5. data/PLAN.md +158 -0
  6. data/README.md +559 -0
  7. data/Rakefile +12 -0
  8. data/app/controllers/acta/web/application_controller.rb +10 -0
  9. data/app/controllers/acta/web/events_controller.rb +37 -0
  10. data/app/helpers/acta/web/application_helper.rb +106 -0
  11. data/app/views/acta/web/events/index.html.erb +312 -0
  12. data/app/views/acta/web/events/show.html.erb +72 -0
  13. data/app/views/layouts/acta/web/application.html.erb +594 -0
  14. data/config/routes.rb +4 -0
  15. data/lib/acta/actor.rb +34 -0
  16. data/lib/acta/adapters/base.rb +59 -0
  17. data/lib/acta/adapters/postgres.rb +73 -0
  18. data/lib/acta/adapters/sqlite.rb +58 -0
  19. data/lib/acta/adapters.rb +19 -0
  20. data/lib/acta/array_type.rb +30 -0
  21. data/lib/acta/command.rb +48 -0
  22. data/lib/acta/current.rb +10 -0
  23. data/lib/acta/errors.rb +102 -0
  24. data/lib/acta/event.rb +80 -0
  25. data/lib/acta/events_query.rb +73 -0
  26. data/lib/acta/handler.rb +9 -0
  27. data/lib/acta/model.rb +58 -0
  28. data/lib/acta/model_type.rb +32 -0
  29. data/lib/acta/projection.rb +64 -0
  30. data/lib/acta/projection_managed.rb +108 -0
  31. data/lib/acta/railtie.rb +65 -0
  32. data/lib/acta/reactor.rb +15 -0
  33. data/lib/acta/reactor_job.rb +19 -0
  34. data/lib/acta/record.rb +10 -0
  35. data/lib/acta/schema.rb +12 -0
  36. data/lib/acta/serializable.rb +48 -0
  37. data/lib/acta/testing/dsl.rb +90 -0
  38. data/lib/acta/testing/matchers.rb +77 -0
  39. data/lib/acta/testing.rb +50 -0
  40. data/lib/acta/types/encrypted_string.rb +63 -0
  41. data/lib/acta/version.rb +5 -0
  42. data/lib/acta/web/engine.rb +13 -0
  43. data/lib/acta/web/events_query.rb +81 -0
  44. data/lib/acta/web.rb +45 -0
  45. data/lib/acta.rb +296 -0
  46. data/lib/generators/acta/install/install_generator.rb +23 -0
  47. data/lib/generators/acta/install/templates/create_acta_events.rb.tt +9 -0
  48. data/sig/acta.rbs +4 -0
  49. metadata +152 -0
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "json"
5
+
6
+ module Acta
7
+ module Web
8
+ module ApplicationHelper
9
+ def acta_chip_hue(event_type)
10
+ h = event_type.to_s.chars.reduce(0) { |acc, c| ((acc * 31 + c.ord) & 0xFFFFFFFF) }
11
+ h.abs % 360
12
+ end
13
+
14
+ def acta_dot_color(event_type)
15
+ "oklch(0.70 0.14 #{acta_chip_hue(event_type)})"
16
+ end
17
+
18
+ def acta_fmt_time(time)
19
+ return "-" unless time
20
+ t = time.respond_to?(:utc) ? time.utc : Time.parse(time.to_s).utc
21
+ ms = t.strftime("%3N")
22
+ t.strftime("%H:%M:%S") + ".#{ms}"
23
+ end
24
+
25
+ def acta_fmt_abs(time)
26
+ return "-" unless time
27
+ t = time.respond_to?(:utc) ? time.utc : Time.parse(time.to_s).utc
28
+ ms = t.strftime("%3N")
29
+ t.strftime("%Y-%m-%d %H:%M:%S") + ".#{ms}Z"
30
+ end
31
+
32
+ def acta_preview_payload(payload)
33
+ return "{}" unless payload.is_a?(Hash) && payload.any?
34
+ masked = acta_mask_encrypted(payload)
35
+ masked.keys.first(3).map { |k| "#{k}=#{masked[k].inspect}" }.join(" ")
36
+ rescue StandardError
37
+ "{}"
38
+ end
39
+
40
+ def acta_pretty_json(obj)
41
+ JSON.pretty_generate(acta_mask_encrypted(obj))
42
+ rescue StandardError
43
+ obj.to_s
44
+ end
45
+
46
+ # Replace any AR::Encryption-recognized ciphertext leaf in `obj`
47
+ # with "********". Walks hashes and arrays; non-encrypted scalars
48
+ # pass through unchanged. Used by the admin UI so encrypted payload
49
+ # values aren't displayed as raw ciphertext envelopes.
50
+ ACTA_MASK = "********"
51
+
52
+ def acta_mask_encrypted(obj)
53
+ case obj
54
+ when Hash then obj.each_with_object({}) { |(k, v), acc| acc[k] = acta_mask_encrypted(v) }
55
+ when Array then obj.map { |v| acta_mask_encrypted(v) }
56
+ when String then acta_encrypted_value?(obj) ? ACTA_MASK : obj
57
+ else obj
58
+ end
59
+ end
60
+
61
+ def acta_encrypted_value?(value)
62
+ return false unless defined?(ActiveRecord::Encryption)
63
+
64
+ ActiveRecord::Encryption.encryptor.encrypted?(value)
65
+ rescue StandardError
66
+ false
67
+ end
68
+
69
+ # Build a URL for the events index, merging +overrides+ into current params.
70
+ # Pass nil for a key to remove it. Resets page when filters change.
71
+ def acta_filter_url(overrides = {})
72
+ current = {
73
+ event_type: params[:event_type],
74
+ stream_type: params[:stream_type],
75
+ actor_id: params[:actor_id],
76
+ stream_key: params[:stream_key],
77
+ q: params[:q],
78
+ selected: params[:selected],
79
+ page: params[:page]
80
+ }.compact_blank
81
+
82
+ overrides = overrides.transform_keys(&:to_sym)
83
+ filter_keys = %i[event_type stream_type actor_id stream_key q]
84
+ current.delete(:page) if (overrides.keys & filter_keys).any?
85
+ current.delete(:selected) if (overrides.keys & filter_keys).any?
86
+
87
+ merged = current.merge(overrides)
88
+ merged.compact_blank!
89
+ merged.delete(:page) if merged[:page].to_s == "0"
90
+
91
+ encode_params(merged)
92
+ end
93
+
94
+ private
95
+
96
+ def encode_params(hash)
97
+ query = hash.map { |k, v| "#{enc(k)}=#{enc(v)}" }.join("&")
98
+ query.empty? ? request.path : "#{request.path}?#{query}"
99
+ end
100
+
101
+ def enc(val)
102
+ URI.encode_www_form_component(val.to_s)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,312 @@
1
+ <%# ── Header ─────────────────────────────────────────────────────────── %>
2
+ <header class="acta-header">
3
+ <a href="<%= acta_filter_url(event_type: nil, stream_type: nil, actor_id: nil, stream_key: nil, q: nil, selected: nil, page: nil) %>" class="acta-logo">
4
+ <div class="acta-logo-dot"></div>
5
+ acta<span class="acta-dim acta-sans" style="font-weight:400">/log</span>
6
+ </a>
7
+
8
+ <form method="get" action="<%= request.path %>" class="acta-filter-bar acta-filter-form">
9
+ <%# Preserve facet filters across text-search submissions %>
10
+ <% %i[event_type stream_type actor_id].each do |f| %>
11
+ <% if params[f].present? %>
12
+ <input type="hidden" name="<%= f %>" value="<%= h params[f] %>">
13
+ <% end %>
14
+ <% end %>
15
+
16
+ <div class="acta-filter-group" style="flex:1 1 320px;max-width:480px;">
17
+ <span class="acta-dim" style="font-size:13px">⌕</span>
18
+ <input
19
+ type="search"
20
+ name="q"
21
+ value="<%= h params[:q] %>"
22
+ placeholder="filter… event type, actor, stream"
23
+ class="acta-filter-input acta-filter-input-q"
24
+ autocomplete="off"
25
+ spellcheck="false"
26
+ >
27
+ </div>
28
+
29
+ <div class="acta-filter-group" style="flex:0 1 240px;min-width:160px;">
30
+ <span class="acta-label acta-dim" style="font-size:10px;font-family:Inter,sans-serif;text-transform:uppercase;letter-spacing:0.6px;white-space:nowrap">stream_key</span>
31
+ <input
32
+ type="search"
33
+ name="stream_key"
34
+ value="<%= h params[:stream_key] %>"
35
+ placeholder="grep stream id…"
36
+ class="acta-filter-input acta-filter-input-sk<%= ' has-value' if params[:stream_key].present? %>"
37
+ autocomplete="off"
38
+ spellcheck="false"
39
+ >
40
+ <% if params[:stream_key].present? %>
41
+ <a href="<%= acta_filter_url(stream_key: nil) %>" class="acta-clear-btn">✕</a>
42
+ <% end %>
43
+ </div>
44
+
45
+ <button type="submit" class="acta-filter-submit acta-sans">Filter</button>
46
+ </form>
47
+
48
+ <div class="acta-status">
49
+ <span><span class="acta-status-dot">●</span> live</span>
50
+ <span><%= @base_count.to_s(:delimited) rescue @base_count %> events</span>
51
+ </div>
52
+ </header>
53
+
54
+ <%# ── Body ───────────────────────────────────────────────────────────── %>
55
+ <div class="acta-body">
56
+
57
+ <%# ── Left rail: facets ──────────────────────────────────────────── %>
58
+ <aside class="acta-rail">
59
+ <details class="acta-facet" open>
60
+ <summary>event_type</summary>
61
+ <% @facet_event_type.each do |event_type, count| %>
62
+ <a
63
+ href="<%= acta_filter_url(event_type: params[:event_type] == event_type ? nil : event_type) %>"
64
+ class="acta-facet-item<%= ' active' if params[:event_type] == event_type %>"
65
+ >
66
+ <span class="acta-facet-dot acta-dot" style="background:<%= acta_dot_color(event_type) %>"></span>
67
+ <span class="acta-facet-name"><%= event_type %></span>
68
+ <span class="acta-facet-count"><%= count %></span>
69
+ </a>
70
+ <% end %>
71
+ </details>
72
+
73
+ <details class="acta-facet" open>
74
+ <summary>stream_type</summary>
75
+ <% @facet_stream_type.each do |stream_type, count| %>
76
+ <a
77
+ href="<%= acta_filter_url(stream_type: params[:stream_type] == stream_type ? nil : stream_type) %>"
78
+ class="acta-facet-item<%= ' active' if params[:stream_type] == stream_type %>"
79
+ >
80
+ <span class="acta-facet-name"><%= stream_type %></span>
81
+ <span class="acta-facet-count"><%= count %></span>
82
+ </a>
83
+ <% end %>
84
+ </details>
85
+
86
+ <details class="acta-facet" open>
87
+ <summary>actor_id</summary>
88
+ <% @facet_actor_id.each do |actor_id, count| %>
89
+ <a
90
+ href="<%= acta_filter_url(actor_id: params[:actor_id] == actor_id ? nil : actor_id) %>"
91
+ class="acta-facet-item<%= ' active' if params[:actor_id] == actor_id %>"
92
+ >
93
+ <span class="acta-facet-name"><%= actor_id %></span>
94
+ <span class="acta-facet-count"><%= count %></span>
95
+ </a>
96
+ <% end %>
97
+ </details>
98
+ </aside>
99
+
100
+ <%# ── Main pane ──────────────────────────────────────────────────── %>
101
+ <main class="acta-main">
102
+
103
+ <%# Active filter chips -%>
104
+ <div class="acta-chips">
105
+ <% if @active_filters.empty? %>
106
+ <span class="acta-dim acta-sans">no filters · <%= @base_count %> events</span>
107
+ <% else %>
108
+ <% if params[:q].present? %>
109
+ <span class="acta-chip acta-sans">
110
+ q:&nbsp;<span class="acta-chip-val"><%= h params[:q] %></span>
111
+ <a href="<%= acta_filter_url(q: nil) %>" class="acta-chip-x">✕</a>
112
+ </span>
113
+ <% end %>
114
+ <% { event_type: params[:event_type], stream_type: params[:stream_type], actor_id: params[:actor_id], stream_key: params[:stream_key] }.compact_blank.each do |key, value| %>
115
+ <span class="acta-chip acta-sans">
116
+ <%= key %>:&nbsp;<span class="acta-chip-val"><%= h value %></span>
117
+ <a href="<%= acta_filter_url(key => nil) %>" class="acta-chip-x">✕</a>
118
+ </span>
119
+ <% end %>
120
+ <span class="acta-chips-count acta-sans"><%= @filtered_count %> of <%= @base_count %></span>
121
+ <% end %>
122
+ </div>
123
+
124
+ <%# Log rows -%>
125
+ <div class="acta-log">
126
+ <% if @base_count == 0 %>
127
+ <%# ── Empty install state ────────────────────────────────────── %>
128
+ <div class="acta-empty-install">
129
+ <div class="acta-empty-badge">
130
+ <span class="acta-dot" style="background:var(--dim)"></span>
131
+ empty log
132
+ </div>
133
+ <div class="acta-empty-title">No events have been recorded yet.</div>
134
+ <p class="acta-empty-desc">
135
+ Acta is wired up — the <code>events</code> table is empty.
136
+ Set an actor at your request boundary and emit your first event,
137
+ then reload this page.
138
+ </p>
139
+
140
+ <div class="acta-empty-steps">
141
+ <div>
142
+ <div class="acta-step-label">
143
+ <span class="acta-step-num">1</span>
144
+ Set the actor at your request boundary
145
+ </div>
146
+ <pre class="acta-code-block"># app/controllers/application_controller.rb
147
+ before_action do
148
+ Acta::Current.actor = Acta::Actor.new(
149
+ type: "user",
150
+ id: current_user.id,
151
+ source: "web"
152
+ )
153
+ end</pre>
154
+ </div>
155
+
156
+ <div>
157
+ <div class="acta-step-label">
158
+ <span class="acta-step-num">2</span>
159
+ Emit an event
160
+ </div>
161
+ <pre class="acta-code-block">Acta.emit(OrderPlaced.new(
162
+ order_id: "o_1",
163
+ customer_id: "c_1",
164
+ total_cents: 4200
165
+ ))</pre>
166
+ </div>
167
+
168
+ <div>
169
+ <div class="acta-step-label">
170
+ <span class="acta-step-num">3</span>
171
+ Reload — the row appears here.
172
+ </div>
173
+ </div>
174
+ </div>
175
+
176
+ <div class="acta-empty-footer">
177
+ <a href="https://github.com/whoojemaflip/acta#readme" target="_blank" rel="noopener" class="acta-empty-link">↗ README</a>
178
+ <a href="https://github.com/whoojemaflip/acta#defining-events" target="_blank" rel="noopener" class="acta-empty-link">↗ Defining events</a>
179
+ <a href="https://github.com/whoojemaflip/acta#commands" target="_blank" rel="noopener" class="acta-empty-link">↗ Commands</a>
180
+ <span class="acta-empty-footer-status">
181
+ <span class="acta-status-dot">●</span> tailing · 0 events
182
+ </span>
183
+ </div>
184
+ </div>
185
+
186
+ <% elsif @events.empty? %>
187
+ <%# ── Zero matches ────────────────────────────────────────────── %>
188
+ <div class="acta-empty-filters">
189
+ <strong>0 matches</strong>
190
+ Loosen filters or <a href="<%= acta_filter_url(event_type: nil, stream_type: nil, actor_id: nil, stream_key: nil, q: nil) %>" style="color:var(--accent);text-decoration:none">clear them</a>.
191
+ </div>
192
+
193
+ <% else %>
194
+ <%# ── Event rows ──────────────────────────────────────────────── %>
195
+ <% @events.each do |event| %>
196
+ <div
197
+ class="acta-row<%= ' selected' if params[:selected] == event.uuid %>"
198
+ data-href="<%= acta_filter_url(selected: event.uuid) %>"
199
+ >
200
+ <span class="acta-row-time" title="<%= acta_fmt_abs(event.recorded_at) %>">
201
+ <%= acta_fmt_time(event.recorded_at) %>
202
+ </span>
203
+
204
+ <span class="acta-row-dot">
205
+ <span class="acta-dot" style="background:<%= acta_dot_color(event.event_type) %>"></span>
206
+ </span>
207
+
208
+ <span class="acta-row-type"><%= event.event_type %></span>
209
+
210
+ <span class="acta-row-stream">
211
+ <span class="acta-dim"><%= event.stream_type %></span>
212
+ <span class="acta-dim" style="opacity:0.5">/</span><%
213
+ %><a
214
+ href="<%= acta_filter_url(stream_key: event.stream_key) %>"
215
+ class="acta-stream-key"
216
+ title="grep stream_key = <%= h event.stream_key %>"
217
+ onClick="event.stopPropagation()"
218
+ ><%= event.stream_key %></a><%
219
+ if event.payload.present? %>
220
+ <span class="acta-payload-preview acta-dim"><%= acta_preview_payload(event.payload) %></span><%
221
+ end %>
222
+ </span>
223
+
224
+ <span class="acta-row-actor">
225
+ <%= event.actor_id %><% if event.source.present? %> <span style="opacity:0.5">·</span> <%= event.source %><% end %>
226
+ </span>
227
+ </div>
228
+ <% end %>
229
+ <% end %>
230
+ </div>
231
+
232
+ <%# Pagination strip -%>
233
+ <div class="acta-pagination">
234
+ <span class="acta-sans"><%= @filtered_count %> matches</span>
235
+ <span class="acta-dim">·</span>
236
+ <span class="acta-sans">page <%= @page + 1 %>/<%= @total_pages %></span>
237
+ <div class="acta-pagination-btns">
238
+ <% if @page > 0 %>
239
+ <a href="<%= acta_filter_url(page: @page - 1) %>" class="acta-page-btn acta-sans">↑ newer</a>
240
+ <% else %>
241
+ <span class="acta-page-btn acta-sans disabled">↑ newer</span>
242
+ <% end %>
243
+ <% if @page < @total_pages - 1 %>
244
+ <a href="<%= acta_filter_url(page: @page + 1) %>" class="acta-page-btn acta-sans">older ↓</a>
245
+ <% else %>
246
+ <span class="acta-page-btn acta-sans disabled">older ↓</span>
247
+ <% end %>
248
+ </div>
249
+ </div>
250
+ </main>
251
+
252
+ <%# ── Right detail panel (when a row is selected) ─────────────────── %>
253
+ <% if @selected_event %>
254
+ <% ev = @selected_event %>
255
+ <aside class="acta-detail">
256
+ <div class="acta-detail-header">
257
+ <span class="acta-dot" style="background:<%= acta_dot_color(ev.event_type) %>"></span>
258
+ <span class="acta-detail-type"><%= ev.event_type %></span>
259
+ <span class="acta-detail-version">v<%= ev.event_version %></span>
260
+ <a href="<%= acta_filter_url(selected: nil) %>" class="acta-detail-close" title="Close">✕</a>
261
+ </div>
262
+
263
+ <div class="acta-detail-body">
264
+ <div class="acta-kv">
265
+ <span class="acta-kv-key">uuid</span>
266
+ <span class="acta-kv-val"><%= ev.uuid %></span>
267
+ </div>
268
+ <div class="acta-kv">
269
+ <span class="acta-kv-key">recorded_at</span>
270
+ <span class="acta-kv-val"><%= acta_fmt_abs(ev.recorded_at) %></span>
271
+ </div>
272
+ <div class="acta-kv">
273
+ <span class="acta-kv-key">occurred_at</span>
274
+ <span class="acta-kv-val"><%= acta_fmt_abs(ev.occurred_at) %></span>
275
+ </div>
276
+ <div class="acta-kv">
277
+ <span class="acta-kv-key">stream</span>
278
+ <span class="acta-kv-val">
279
+ <a href="<%= acta_filter_url(stream_type: ev.stream_type, stream_key: ev.stream_key, selected: nil) %>">
280
+ <%= ev.stream_type %>/<%= ev.stream_key %><% if ev.respond_to?(:stream_sequence) && ev.stream_sequence.present? %> #<%= ev.stream_sequence %><% end %>
281
+ </a>
282
+ </span>
283
+ </div>
284
+ <div class="acta-kv">
285
+ <span class="acta-kv-key">actor</span>
286
+ <span class="acta-kv-val">
287
+ <a href="<%= acta_filter_url(actor_id: ev.actor_id, selected: nil) %>"><%= ev.actor_type %>/<%= ev.actor_id %></a>
288
+ </span>
289
+ </div>
290
+ <div class="acta-kv">
291
+ <span class="acta-kv-key">source</span>
292
+ <span class="acta-kv-val"><%= ev.source %></span>
293
+ </div>
294
+
295
+ <div class="acta-section-label">payload</div>
296
+ <pre class="acta-json-block"><%= acta_pretty_json(ev.payload) %></pre>
297
+
298
+ <% if ev.respond_to?(:metadata) && ev.metadata.present? && ev.metadata != {} %>
299
+ <div class="acta-section-label">metadata</div>
300
+ <pre class="acta-json-block"><%= acta_pretty_json(ev.metadata) %></pre>
301
+ <% end %>
302
+
303
+ <div style="margin-top:18px">
304
+ <a href="<%= url_for(controller: "acta/web/events", action: "show", id: ev.uuid) %>" class="acta-sans" style="color:var(--accent);text-decoration:none;font-size:10px">
305
+ ↗ permalink
306
+ </a>
307
+ </div>
308
+ </div>
309
+ </aside>
310
+ <% end %>
311
+
312
+ </div>
@@ -0,0 +1,72 @@
1
+ <%# Standalone event detail view — deep-linkable permalink for a single event %>
2
+
3
+ <header class="acta-header">
4
+ <a href="<%= url_for(controller: "acta/web/events", action: "index") %>" class="acta-logo">
5
+ <div class="acta-logo-dot"></div>
6
+ acta<span class="acta-dim acta-sans" style="font-weight:400">/log</span>
7
+ </a>
8
+ <span class="acta-dim" style="margin-left:8px;font-size:11px">·</span>
9
+ <span class="acta-dim" style="font-size:11px;margin-left:8px"><%= @event.event_type %></span>
10
+ <span class="acta-dim" style="margin-left:auto;font-size:10px;font-family:Inter,sans-serif">
11
+ <a href="<%= url_for(controller: "acta/web/events", action: "index", selected: @event.uuid) %>" style="color:var(--accent);text-decoration:none">
12
+ ← back to log
13
+ </a>
14
+ </span>
15
+ </header>
16
+
17
+ <div class="acta-body">
18
+ <main class="acta-main" style="max-width:700px;padding:24px 32px;overflow-y:auto;">
19
+
20
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:20px;">
21
+ <span class="acta-dot" style="background:<%= acta_dot_color(@event.event_type) %>;width:10px;height:10px;"></span>
22
+ <span style="font-size:16px;color:var(--fg);"><%= @event.event_type %></span>
23
+ <span class="acta-sans acta-dim" style="font-size:11px;">v<%= @event.event_version %></span>
24
+ </div>
25
+
26
+ <div class="acta-kv">
27
+ <span class="acta-kv-key">uuid</span>
28
+ <span class="acta-kv-val"><%= @event.uuid %></span>
29
+ </div>
30
+ <div class="acta-kv">
31
+ <span class="acta-kv-key">recorded_at</span>
32
+ <span class="acta-kv-val"><%= acta_fmt_abs(@event.recorded_at) %></span>
33
+ </div>
34
+ <div class="acta-kv">
35
+ <span class="acta-kv-key">occurred_at</span>
36
+ <span class="acta-kv-val"><%= acta_fmt_abs(@event.occurred_at) %></span>
37
+ </div>
38
+ <div class="acta-kv">
39
+ <span class="acta-kv-key">stream</span>
40
+ <span class="acta-kv-val">
41
+ <a href="<%= url_for(controller: "acta/web/events", action: "index", stream_type: @event.stream_type, stream_key: @event.stream_key) %>">
42
+ <%= @event.stream_type %>/<%= @event.stream_key %><% if @event.respond_to?(:stream_sequence) && @event.stream_sequence.present? %> #<%= @event.stream_sequence %><% end %>
43
+ </a>
44
+ </span>
45
+ </div>
46
+ <div class="acta-kv">
47
+ <span class="acta-kv-key">actor</span>
48
+ <span class="acta-kv-val">
49
+ <a href="<%= url_for(controller: "acta/web/events", action: "index", actor_id: @event.actor_id) %>">
50
+ <%= @event.actor_type %>/<%= @event.actor_id %>
51
+ </a>
52
+ </span>
53
+ </div>
54
+ <div class="acta-kv">
55
+ <span class="acta-kv-key">source</span>
56
+ <span class="acta-kv-val"><%= @event.source %></span>
57
+ </div>
58
+ <div class="acta-kv">
59
+ <span class="acta-kv-key">event_version</span>
60
+ <span class="acta-kv-val"><%= @event.event_version %></span>
61
+ </div>
62
+
63
+ <div class="acta-section-label">payload</div>
64
+ <pre class="acta-json-block"><%= acta_pretty_json(@event.payload) %></pre>
65
+
66
+ <% if @event.respond_to?(:metadata) && @event.metadata.present? && @event.metadata != {} %>
67
+ <div class="acta-section-label">metadata</div>
68
+ <pre class="acta-json-block"><%= acta_pretty_json(@event.metadata) %></pre>
69
+ <% end %>
70
+
71
+ </main>
72
+ </div>