ruby_event_store-browser 3.0.1 → 3.1.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/ruby_event_store/browser/app.rb +190 -86
  4. data/lib/ruby_event_store/browser/extension_context.rb +22 -0
  5. data/lib/ruby_event_store/browser/get_events_from_stream.rb +50 -93
  6. data/lib/ruby_event_store/browser/get_events_from_streams.rb +98 -0
  7. data/lib/ruby_event_store/browser/layout.rb +55 -0
  8. data/lib/ruby_event_store/browser/renderer.rb +64 -0
  9. data/lib/ruby_event_store/browser/urls.rb +35 -39
  10. data/lib/ruby_event_store/browser/version.rb +1 -1
  11. data/lib/ruby_event_store/browser/views/_clipboard_icon.html.erb +1 -0
  12. data/lib/ruby_event_store/browser/views/_clock_icon.html.erb +1 -0
  13. data/lib/ruby_event_store/browser/views/_json_tree.html.erb +1 -0
  14. data/lib/ruby_event_store/browser/views/_raw_section.html.erb +9 -0
  15. data/lib/ruby_event_store/browser/views/_timestamp.html.erb +7 -0
  16. data/lib/ruby_event_store/browser/views/events/show.html.erb +85 -0
  17. data/lib/ruby_event_store/browser/views/layout.html.erb +72 -0
  18. data/lib/ruby_event_store/browser/views/not_found.html.erb +3 -0
  19. data/lib/ruby_event_store/browser/views/streams/show.html.erb +74 -0
  20. data/lib/ruby_event_store/browser/views/swimlane/_rows.html.erb +20 -0
  21. data/lib/ruby_event_store/browser/views/swimlane/show.html.erb +55 -0
  22. data/lib/ruby_event_store/browser.rb +17 -5
  23. data/public/ruby_event_store_browser.css +2 -1
  24. data/public/ruby_event_store_browser.js +287 -1
  25. data/public/stimulus-3.2.2.js +11 -0
  26. metadata +21 -11
  27. data/lib/ruby_event_store/browser/gem_source.rb +0 -32
  28. data/lib/ruby_event_store/browser/get_event.rb +0 -37
  29. data/lib/ruby_event_store/browser/get_stream.rb +0 -29
  30. data/lib/ruby_event_store/browser/json_api_event.rb +0 -66
  31. data/lib/ruby_event_store/browser/json_api_stream.rb +0 -34
  32. data/public/bootstrap.js +0 -30
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyEventStore
4
+ module Browser
5
+ class Layout
6
+ def initialize(stylesheets_resolver, scripts_resolver, nav_links_resolver, views_roots:)
7
+ @stylesheets_resolver = stylesheets_resolver
8
+ @scripts_resolver = scripts_resolver
9
+ @nav_links_resolver = nav_links_resolver
10
+ @views_roots = views_roots
11
+ end
12
+
13
+ def with_views_root(views_root)
14
+ Layout.new(
15
+ @stylesheets_resolver,
16
+ @scripts_resolver,
17
+ @nav_links_resolver,
18
+ views_roots: @views_roots + [views_root],
19
+ )
20
+ end
21
+
22
+ def render(template, urls:, title:, **locals)
23
+ content = renderer.render(template, urls: urls, **locals)
24
+ [200, { "content-type" => "text/html;charset=utf-8" }, [wrap(renderer, content, urls, title)]]
25
+ end
26
+
27
+ def render_partial(template, urls:, **locals)
28
+ renderer.render(template, urls: urls, **locals)
29
+ end
30
+
31
+ def not_found(urls, message:)
32
+ content = renderer.render("not_found", message: message)
33
+ [404, { "content-type" => "text/html;charset=utf-8" }, [wrap(renderer, content, urls, "Not found")]]
34
+ end
35
+
36
+ private
37
+
38
+ def renderer
39
+ Renderer.new([*@views_roots, Renderer::VIEWS_ROOT])
40
+ end
41
+
42
+ def wrap(renderer, content, urls, title)
43
+ renderer.render(
44
+ "layout",
45
+ content: content,
46
+ urls: urls,
47
+ title: title,
48
+ extension_stylesheets: @stylesheets_resolver.call(urls),
49
+ extension_scripts: @scripts_resolver.call(urls),
50
+ extension_nav_links: @nav_links_resolver.call(urls),
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+
6
+ module RubyEventStore
7
+ module Browser
8
+ class Renderer
9
+ VIEWS_ROOT = File.expand_path("views", __dir__).freeze
10
+
11
+ class Context
12
+ include ERB::Util
13
+ alias_method :h, :html_escape
14
+
15
+ def initialize(renderer, locals)
16
+ @_renderer = renderer
17
+ locals.each { |k, v| define_singleton_method(k) { v } }
18
+ end
19
+
20
+ def render(template, **locals)
21
+ @_renderer.render(template, **locals)
22
+ end
23
+
24
+ def safe_json(value)
25
+ if value.is_a?(Float) && value.infinite? && value.positive?
26
+ '"Infinity"'
27
+ elsif value.is_a?(Float) && value.infinite?
28
+ '"-Infinity"'
29
+ elsif value.is_a?(Float) && value.nan?
30
+ '"NaN"'
31
+ elsif value.is_a?(Float) && value == value.to_i
32
+ value.to_i.to_s
33
+ elsif value.is_a?(Hash)
34
+ "{#{value.map { |k, v| "#{k.to_json}:#{safe_json(v)}" }.join(",")}}"
35
+ elsif value.is_a?(Array)
36
+ "[#{value.map { |v| safe_json(v) }.join(",")}]"
37
+ else
38
+ value.to_json
39
+ end
40
+ end
41
+
42
+ def get_binding
43
+ binding
44
+ end
45
+ end
46
+
47
+ def initialize(views_roots = [VIEWS_ROOT])
48
+ @views_roots = views_roots
49
+ end
50
+
51
+ def render(template, **locals)
52
+ context = Context.new(self, locals)
53
+ ERB.new(File.read(template_path(template)), trim_mode: "-").result(context.get_binding)
54
+ end
55
+
56
+ private
57
+
58
+ def template_path(template)
59
+ @views_roots.map { |root| File.join(root, "#{template}.html.erb") }.find { |path| File.exist?(path) } ||
60
+ raise(ArgumentError, "Template not found: #{template}")
61
+ end
62
+ end
63
+ end
64
+ end
@@ -3,78 +3,74 @@
3
3
  module RubyEventStore
4
4
  module Browser
5
5
  class Urls
6
- def self.from_configuration(host, root_path, api_url = nil)
7
- new(host, root_path, api_url)
6
+ def self.from_configuration(host, root_path)
7
+ new(host, root_path)
8
8
  end
9
9
 
10
10
  def self.initial
11
- new(nil, nil, nil)
11
+ new(nil, nil)
12
12
  end
13
13
 
14
14
  def with_request(request)
15
- Urls.new(host || request.base_url, root_path || request.script_name, api_url)
15
+ Urls.new(host || request.base_url, root_path || request.script_name)
16
16
  end
17
17
 
18
- attr_reader :app_url, :api_url, :host, :root_path
18
+ attr_reader :app_url, :host, :root_path
19
19
 
20
- def initialize(host, root_path, api_url)
20
+ def initialize(host, root_path)
21
21
  @host = host
22
22
  @root_path = root_path
23
23
  @app_url = [host, root_path].compact.reduce(:+)
24
- @api_url = api_url || ("#{app_url}/api" if app_url)
25
- @gem_source = GemSource.new($LOAD_PATH)
26
24
  end
27
25
 
28
- def events_url
29
- "#{api_url}/events"
26
+ def stream_url(stream_name)
27
+ "#{app_url}/streams/#{Rack::Utils.escape(stream_name)}"
30
28
  end
31
29
 
32
- def streams_url
33
- "#{api_url}/streams"
30
+ def event_url(event_id)
31
+ "#{app_url}/events/#{event_id}"
34
32
  end
35
33
 
36
- def paginated_events_from_stream_url(id:, position: nil, direction: nil, count: nil)
37
- stream_name = Rack::Utils.escape(id)
38
- query_string =
34
+ def stream_page_url(stream_name, cursor, count)
35
+ query =
39
36
  URI.encode_www_form(
40
- { "page[position]" => position, "page[direction]" => direction, "page[count]" => count }.compact,
37
+ [["page[position]", cursor[:position]], ["page[direction]", cursor[:direction]], ["page[count]", count]],
41
38
  )
42
-
43
- if query_string.empty?
44
- "#{api_url}/streams/#{stream_name}/relationships/events"
45
- else
46
- "#{api_url}/streams/#{stream_name}/relationships/events?#{query_string}"
47
- end
39
+ "#{stream_url(stream_name)}?#{query}"
48
40
  end
49
41
 
50
- def browser_js_url
51
- name = "ruby_event_store_browser.js"
52
- gem_source.from_git? ? cdn_file_url(name) : local_file_url(name)
42
+ def swimlane_url(stream_names, sort = nil)
43
+ "#{app_url}/swimlane?#{swimlane_query(stream_names, sort)}"
53
44
  end
54
45
 
55
- def browser_css_url
56
- name = "ruby_event_store_browser.css"
57
- gem_source.from_git? ? cdn_file_url(name) : local_file_url(name)
46
+ def swimlane_more_url(stream_names, cursor, sort)
47
+ "#{app_url}/swimlane/more?#{swimlane_query(stream_names, sort, [["cursor", cursor]])}"
58
48
  end
59
49
 
60
- def bootstrap_js_url
61
- "#{app_url}/bootstrap.js"
50
+ def app_url_for(*segments, query: nil)
51
+ path = segments.map { |segment| Rack::Utils.escape(segment) }.join("/")
52
+ query ? "#{app_url}/#{path}?#{URI.encode_www_form(query)}" : "#{app_url}/#{path}"
62
53
  end
63
54
 
64
- def ==(o)
65
- self.class.eql?(o.class) && app_url.eql?(o.app_url) && api_url.eql?(o.api_url)
55
+ def browser_js_url
56
+ "#{app_url}/#{BROWSER_JS}"
66
57
  end
67
58
 
68
- private
69
-
70
- attr_reader :gem_source
59
+ def browser_css_url
60
+ "#{app_url}/#{BROWSER_CSS}"
61
+ end
71
62
 
72
- def local_file_url(name)
73
- "#{app_url}/#{name}"
63
+ def ==(other)
64
+ self.class.eql?(other.class) && app_url.eql?(other.app_url)
74
65
  end
75
66
 
76
- def cdn_file_url(name)
77
- "https://cdn.railseventstore.org/#{gem_source.version}/#{name}"
67
+ private
68
+
69
+ def swimlane_query(stream_names, sort, extra = [])
70
+ pairs = stream_names.map { |name| ["streams[]", name] }
71
+ pairs.concat(extra)
72
+ pairs << ["sort", sort] if sort
73
+ URI.encode_www_form(pairs)
78
74
  end
79
75
  end
80
76
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyEventStore
4
4
  module Browser
5
- VERSION = "3.0.1"
5
+ VERSION = "3.1.0"
6
6
  end
7
7
  end
@@ -0,0 +1 @@
1
+ <svg class="<%= classes %>" fill="none" height="<%= size %>" width="<%= size %>" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>
@@ -0,0 +1 @@
1
+ <svg class="size-3 text-gray-400" fill="none" height="24" width="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
@@ -0,0 +1 @@
1
+ <div class="font-[monospace] whitespace-pre">{<ul class="list-none ml-[26px] pl-0"><% hash.sort_by { |key, _| key.to_s }.each do |key, value| %><li class="relative"><span class="font-bold"><%= h(key) %></span>: <span class="<%= case value when String then "text-[green]" when Numeric then "text-[blue]" when true, false then "text-[firebrick]" when nil then "text-[gray]" else "text-gray-800" end %>"><%= h(safe_json(value)) %></span>,</li><% end %></ul>}</div>
@@ -0,0 +1,9 @@
1
+ <section class="space-y-4">
2
+ <header class="flex justify-between border-gray-400 border-b text-xs pb-2">
3
+ <h2 class="text-gray-500 uppercase font-bold"><%= title %></h2>
4
+ <button data-controller="clipboard" data-clipboard-text-value="<%= h(safe_json(hash)) %>" data-action="clipboard#copy" class="text-red-700 no-underline"><%= render("_clipboard_icon", classes: "text-gray-400 hover:text-red-700", size: 16) %></button>
5
+ </header>
6
+ <div class="overflow-auto w-full">
7
+ <%= render("_json_tree", hash: hash) %>
8
+ </div>
9
+ </section>
@@ -0,0 +1,7 @@
1
+ <section class="space-y-1<%= top ? " pt-3" : "" %> cursor-help" data-timezone-target="zone" title="UTC">
2
+ <header class="flex items-center gap-1 text-xs">
3
+ <%= render("_clock_icon") %>
4
+ <h2 class="text-gray-500 uppercase font-bold"><%= title %></h2>
5
+ </header>
6
+ <div class="overflow-auto w-full text-sm font-bold font-mono pl-4 text-gray-700 tracking-tight" data-timezone-target="time" data-iso="<%= time.utc.iso8601(6) %>"><%= time.utc.strftime("%Y-%m-%dT%H:%M:%S.%3N") %></div>
7
+ </section>
@@ -0,0 +1,85 @@
1
+ <div class="py-8 md:py-12 container mx-auto space-y-10">
2
+ <header class="flex items-start justify-between gap-4 flex-wrap md:flex-nowrap">
3
+ <div class="flex flex-col items-start">
4
+ <h1 class="font-bold text-2xl break-words min-w-0 mb-2"><%= h(event.event_type) %></h1>
5
+ <p class="flex gap-2 md:items-center min-w-0 text-sm flex-wrap mb-4">
6
+ <span class="whitespace-nowrap text-xs text-gray-500 uppercase font-bold">Event ID:</span>
7
+ <button data-controller="clipboard" data-clipboard-text-value="<%= event.event_id %>" data-action="clipboard#copy" class="flex items-center text-left gap-2 group font-mono text-gray-800 font-bold text-sm">
8
+ <%= event.event_id %>
9
+ <%= render("_clipboard_icon", classes: "size-4 -translate-y-0.5 opacity-0 group-hover:opacity-100", size: 24) %>
10
+ </button>
11
+ </p>
12
+ <% if extension_links.any? -%>
13
+ <div class="flex gap-2">
14
+ <% extension_links.each do |link| -%>
15
+ <a class="inline-block text-center text-sm bg-red-700 text-gray-100 border border-red-700 rounded px-2 py-1" href="<%= h(link[:url]) %>"><%= h(link[:label]) %></a>
16
+ <% end -%>
17
+ </div>
18
+ <% end -%>
19
+ </div>
20
+ <div class="space-y-4">
21
+ <%= render("_timestamp", title: "Created at", time: event.metadata[:timestamp], top: true) %>
22
+ <%= render("_timestamp", title: "Valid at", time: event.metadata[:valid_at], top: false) %>
23
+ </div>
24
+ </header>
25
+
26
+ <div class="w-full text-left grid md:grid-cols-2 gap-8 overflow-hidden">
27
+ <%= render("_raw_section", title: "Raw Data", hash: event.data) %>
28
+ <%= render("_raw_section", title: "Raw Metadata", hash: metadata) %>
29
+ </div>
30
+
31
+ <div>
32
+ <h2 class="font-bold text-xl mb-4">Event streams</h2>
33
+ <ul class="list-disc pl-4 space-y-2">
34
+ <% streams.each do |stream| -%>
35
+ <li>
36
+ <a class="text-red-700 no-underline" href="<%= urls.stream_url(stream) %>"><%= h(stream) %></a>
37
+ </li>
38
+ <% end -%>
39
+ </ul>
40
+ </div>
41
+
42
+ <% if parent_event -%>
43
+ <div>
44
+ <h2 class="font-bold text-xl mb-4">Related</h2>
45
+ <ul class="list-disc pl-4 space-y-2">
46
+ <li>Parent event: <a class="text-red-700 no-underline" href="<%= urls.event_url(parent_event.event_id) %>"><%= parent_event.event_id %></a></li>
47
+ </ul>
48
+ </div>
49
+ <% end -%>
50
+
51
+ <% if caused_by.any? -%>
52
+ <div class="space-y-4">
53
+ <h2 class="font-bold text-xl">Events caused by this event:</h2>
54
+ <table class="w-full text-left table-fixed">
55
+ <thead class="align-bottom leading-tight sticky top-0 bg-white/70 backdrop-blur-sm text-gray-500 uppercase text-xs">
56
+ <tr class="border-gray-400 border-b">
57
+ <th class="p-4">Event name</th>
58
+ <th class="py-4 pr-4 lg:w-80">Event id</th>
59
+ </tr>
60
+ </thead>
61
+ <tbody class="align-top">
62
+ <% caused_by.each do |child| -%>
63
+ <tr class="border-gray-50 border-b hover:bg-gray-100">
64
+ <td>
65
+ <a class="text-red-700 no-underline min-h-11 w-full flex items-center px-4" href="<%= urls.event_url(child.event_id) %>"><%= h(child.event_type) %></a>
66
+ </td>
67
+ <td class="font-mono text-sm leading-none font-medium align-middle">
68
+ <a class="no-underline h-full min-h-11 flex items-center" href="<%= urls.event_url(child.event_id) %>"><%= child.event_id %></a>
69
+ </td>
70
+ </tr>
71
+ <% end -%>
72
+ </tbody>
73
+ <tfoot>
74
+ <tr>
75
+ <td class="text-gray-500 py-4 text-center text-xs" colspan="2"><%= "The results may be truncated, check stream for full information." if caused_by.size == PAGE_SIZE %></td>
76
+ </tr>
77
+ </tfoot>
78
+ </table>
79
+ </div>
80
+ <% else -%>
81
+ <div>
82
+ <h2 class="font-bold text-xl">Events caused by this event: none</h2>
83
+ </div>
84
+ <% end -%>
85
+ </div>
@@ -0,0 +1,72 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <title>RubyEventStore::Browser<% if defined?(title) && title %> - <%= h(title) %><% end %></title>
5
+ <link type="text/css" rel="stylesheet" href="<%= urls.browser_css_url %>">
6
+ <% extension_stylesheets.each do |href| -%>
7
+ <link type="text/css" rel="stylesheet" href="<%= href %>">
8
+ <% end -%>
9
+ <meta name="robots" content="noindex, nofollow">
10
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
11
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
12
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
13
+ <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
14
+ <meta name="msapplication-TileColor" content="#da532c">
15
+ <meta name="theme-color" content="#ffffff">
16
+ </head>
17
+ <body data-controller="swimlane" data-swimlane-base-url-value="<%= urls.app_url %>">
18
+ <div class="flex flex-col w-full min-h-screen font-sans antialiased leading-relaxed text-gray-800 bg-gray-100" data-controller="search timezone" data-search-base-value="<%= urls.app_url %>" data-action="keydown.meta+k@window->search#open keydown.ctrl+k@window->search#open">
19
+ <header>
20
+ <nav class="sticky flex justify-between h-16 px-8 bg-red-700">
21
+ <div class="flex items-center gap-6">
22
+ <a href="<%= urls.app_url %>" class="font-semibold text-gray-100">Ruby Event Store</a>
23
+ <% extension_nav_links.each do |link| -%>
24
+ <a href="<%= h(link[:url]) %>" class="text-sm text-red-100 hover:text-white"><%= h(link[:label]) %></a>
25
+ <% end -%>
26
+ </div>
27
+ <div class="flex items-center gap-2">
28
+ <button class="flex items-center gap-2 px-3 text-sm text-red-100 bg-red-800 rounded outline-none hover:bg-red-900 h-9" data-action="search#open">
29
+ <svg class="size-4" fill="none" height="24" width="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>Go to stream&hellip;<span class="text-xs">&#x2318;K</span>
30
+ </button>
31
+ </div>
32
+ </nav>
33
+ </header>
34
+ <main class="flex w-full px-4 bg-white grow">
35
+ <%= content %>
36
+ <dialog data-search-target="dialog" class="open:flex flex-col items-center justify-center fixed inset-0 size-full max-w-full max-h-full bg-transparent backdrop:bg-black/50">
37
+ <button class="fixed inset-0 -z-10 cursor-default" data-action="search#close" tabindex="-1" aria-label="Close search"></button>
38
+ <form data-action="search#go" class="w-full max-w-md p-4 bg-white rounded-lg shadow-xl">
39
+ <div class="relative">
40
+ <svg class="size-4 text-gray-400 absolute pointer-events-none top-3.5 left-2" fill="none" height="24" width="24" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
41
+ <input data-search-target="input" class="w-full py-2 pl-8 pr-12 text-gray-800 rounded outline-none appearance-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50" placeholder="Go to stream&hellip;" autofocus>
42
+ <span class="absolute top-0 h-full flex items-center right-3 text-[.5rem] pointer-events-none">
43
+ <span class="block p-1 font-bold text-gray-500 border border-gray-300 rounded bg-gray-50">ESC</span>
44
+ </span>
45
+ </div>
46
+ </form>
47
+ </dialog>
48
+ </main>
49
+ <footer class="flex justify-between px-8 py-4 pb-20 border-t border-gray-400">
50
+ <div class="text-sm text-gray-500">RubyEventStore v<%= RubyEventStore::VERSION %><span class="inline-block ml-4 font-bold text-gray-400">&bull;</span><a href="https://railseventstore.org/docs/getting-started/install" class="ml-4">Documentation</a><span class="inline-block ml-4 font-bold text-gray-400">&bull;</span><a href="https://railseventstore.org/support/" class="ml-4">Support</a></div>
51
+ <div class="flex gap-2 text-sm text-gray-500 item-center">Display times in timezone:<select data-timezone-target="select" data-action="change->timezone#change">
52
+ <option value="UTC">UTC</option>
53
+ </select>
54
+ </div>
55
+ </footer>
56
+ </div>
57
+ <script type="module" src="<%= urls.browser_js_url %>"></script>
58
+ <% extension_scripts.each do |src| -%>
59
+ <script type="module" src="<%= src %>"></script>
60
+ <% end -%>
61
+ <div data-swimlane-target="drawer" class="fixed z-10 hidden has-[li]:flex flex-col items-end justify-end gap-4 bottom-8 right-8">
62
+ <div data-swimlane-target="drawer-content" class="hidden [[data-swimlane-drawer-open]_&]:flex flex-col items-stretch gap-3 p-4 bg-white rounded-md shadow-md w-72 max-w-[90vw]">
63
+ <ul data-swimlane-target="drawerList" class="flex flex-col divide-y divide-gray-100"></ul>
64
+ <button data-action="swimlane#go" title="Go to swimlane view" class="inline-flex items-center justify-center gap-2 px-3 py-1.5 text-sm text-gray-100 bg-red-700 rounded hover:bg-red-800">
65
+ <span>Go to swimlane view</span>
66
+ <svg class="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
67
+ </button>
68
+ </div>
69
+ <button class="flex items-center justify-center text-xl text-white bg-blue-800 rounded-full size-10" data-action="swimlane#toggleDrawer"><span class="sr-only">Toggle swimlane drawer</span>≈</button>
70
+ </div>
71
+ </body>
72
+ </html>
@@ -0,0 +1,3 @@
1
+ <div class="py-12">
2
+ <h1 class="font-bold px-8 text-2xl"><%= h(message) %></h1>
3
+ </div>
@@ -0,0 +1,74 @@
1
+ <div class="container py-8 mx-auto">
2
+ <div class="flex justify-between">
3
+ <h1 class="text-2xl font-bold">Events in <%= h(stream_name) %> </h1>
4
+ <div>
5
+ <ul class="flex">
6
+ <% [["first", pagination[:first]], ["previous", pagination[:prev]], ["next", pagination[:next]], ["last", pagination[:last]]].each do |label, url| -%>
7
+ <li>
8
+ <% if url -%>
9
+ <a class="px-2 py-1 mr-1 text-sm text-center text-red-700 border border-red-700 rounded" href="<%= url %>"><%= label %></a>
10
+ <% else -%>
11
+ <a class="px-2 py-1 mr-1 text-sm text-center border rounded cursor-not-allowed text-red-700/50 border-red-700/50"><%= label %></a>
12
+ <% end -%>
13
+ </li>
14
+ <% end -%>
15
+ </ul>
16
+ </div>
17
+ </div>
18
+ <% unless stream_name == SERIALIZED_GLOBAL_STREAM_NAME -%>
19
+ <button class="inline-flex items-center gap-1 px-2 py-1 mt-3 text-sm text-center text-gray-100 bg-blue-800 border border-blue-800 rounded hover:bg-blue-900" data-stream-name="<%= h(stream_name) %>" data-action="swimlane#add">
20
+ <svg class="pointer-events-none size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
21
+ Add to swimlane
22
+ </button>
23
+ <% end -%>
24
+
25
+ <% links = [*extension_links].compact -%>
26
+ <% if links.any? -%>
27
+ <div class="flex gap-2 pt-3">
28
+ <% links.each do |link| -%>
29
+ <a class="inline-block px-2 py-1 text-sm text-center text-gray-100 bg-red-700 border border-red-700 rounded" href="<%= h(link[:url]) %>"><%= h(link[:label]) %></a>
30
+ <% end -%>
31
+ </div>
32
+ <% end -%>
33
+ <div>
34
+ <div class="w-full overflow-x-scroll sm:overflow-visible">
35
+ <table class="w-full my-10 text-left lg:table-fixed">
36
+ <thead class="sticky top-0 text-xs leading-tight text-gray-500 uppercase align-bottom bg-white/70 backdrop-blur-sm">
37
+ <tr class="border-b border-gray-400">
38
+ <th class="p-4">Event name</th>
39
+ <th class="py-4 pr-4 lg:w-80">Event id</th>
40
+ <th class="py-4 pr-4 text-right lg:w-60"><span class="cursor-help" data-timezone-target="zone" title="UTC">Created at</span></th>
41
+ </tr>
42
+ </thead>
43
+ <tbody class="align-top">
44
+ <% events.each do |event| -%>
45
+ <% href = urls.event_url(event.event_id) -%>
46
+ <tr class="border-b border-gray-50 hover:bg-gray-100">
47
+ <td>
48
+ <a class="flex items-center w-full px-4 text-red-700 no-underline min-h-11" href="<%= href %>"><span class="min-w-0 break-words"><%= h(event.event_type) %></span></a>
49
+ </td>
50
+ <td class="font-mono text-sm font-medium leading-none align-middle">
51
+ <a class="flex items-center h-full no-underline min-h-11" href="<%= href %>"><%= event.event_id %></a>
52
+ </td>
53
+ <td class="font-mono text-sm font-medium leading-none">
54
+ <a class="flex items-center justify-end px-4 no-underline min-h-11" href="<%= href %>"><span data-timezone-target="time" data-iso="<%= event.metadata[:timestamp].utc.iso8601(6) %>" title="UTC"><%= event.metadata[:timestamp].utc.strftime("%Y-%m-%dT%H:%M:%S.%3N") %></span></a>
55
+ </td>
56
+ </tr>
57
+ <% end -%>
58
+ </tbody>
59
+ </table>
60
+ </div>
61
+ </div>
62
+ <div>
63
+ <% if related_streams && related_streams.any? -%>
64
+ <div>
65
+ <h2 class="text-xl font-bold">Related streams:</h2>
66
+ <ul class="pl-8 list-disc">
67
+ <% related_streams.each do |stream| -%>
68
+ <li><a class="text-red-700 no-underline" href="<%= urls.stream_url(stream) %>"><%= h(stream) %></a></li>
69
+ <% end -%>
70
+ </ul>
71
+ </div>
72
+ <% end -%>
73
+ </div>
74
+ </div>
@@ -0,0 +1,20 @@
1
+ <% time_field = sort == "as_of" ? :valid_at : :timestamp -%>
2
+ <% events.each_with_index do |(names, event), index| -%>
3
+ <% next_event = events[index + 1]&.last -%>
4
+ <% same_group = next_event && next_event.metadata[time_field].utc.iso8601(3) == event.metadata[time_field].utc.iso8601(3) -%>
5
+ <% cell = "align-middle px-2 py-1#{" border-b border-gray-300" unless same_group}" -%>
6
+ <tr>
7
+ <td class="<%= cell %> font-mono text-xs text-gray-500 whitespace-nowrap">
8
+ <% unless same_group -%>
9
+ <span data-swimlane-view-target="time" data-iso="<%= event.metadata[time_field].utc.iso8601(6) %>" title="UTC"><%= event.metadata[time_field].utc.strftime("%Y-%m-%dT%H:%M:%S.%3N") %></span>
10
+ <% end -%>
11
+ </td>
12
+ <% stream_names.each_with_index do |stream_name, column| -%>
13
+ <td class="<%= cell %><%= " border-l border-gray-200" if column.positive? %><%= " hover:bg-gray-100" if names.include?(stream_name) %>">
14
+ <% if names.include?(stream_name) -%>
15
+ <a href="<%= urls.event_url(event.event_id) %>" class="no-underline text-red-700 text-sm break-words"><%= h(event.event_type) %></a>
16
+ <% end -%>
17
+ </td>
18
+ <% end -%>
19
+ </tr>
20
+ <% end -%>
@@ -0,0 +1,55 @@
1
+ <div class="w-full shrink-0" data-controller="swimlane-view swimlane-add" data-swimlane-view-more-url-value="<%= more_url %>" data-swimlane-add-base-url-value="<%= urls.swimlane_url(stream_names, sort) %>">
2
+ <div class="container py-8 mx-auto">
3
+ <div class="flex items-center justify-between">
4
+ <h1 class="text-2xl font-bold">Comparing <%= h(stream_names.join(", ")) %></h1>
5
+ <button class="px-2 py-1 text-sm text-center text-red-700 border border-red-700 rounded" data-action="swimlane-add#open">Add stream</button>
6
+ </div>
7
+ <% missing_stream_names.each do |stream_name| -%>
8
+ <p class="px-4 py-3 mt-4 text-sm text-yellow-800 border border-yellow-300 rounded bg-yellow-50">
9
+ Stream <span class="font-bold"><%= h(stream_name) %></span> does not exist.
10
+ </p>
11
+ <% end -%>
12
+ <% if stream_names.any? -%>
13
+ <div class="flex items-center gap-2 pt-3 text-sm">
14
+ <span class="text-gray-500">Sort by</span>
15
+ <% if sort == "as_of" -%>
16
+ <a class="text-gray-500 no-underline hover:text-red-700" href="<%= urls.swimlane_url(stream_names) %>">created at</a>
17
+ <span class="font-bold text-red-700">valid at</span>
18
+ <% else -%>
19
+ <span class="font-bold text-red-700">created at</span>
20
+ <a class="text-gray-500 no-underline hover:text-red-700" href="<%= urls.swimlane_url(stream_names, "as_of") %>">valid at</a>
21
+ <% end -%>
22
+ </div>
23
+ <div class="my-10 overflow-x-auto">
24
+ <table class="text-left">
25
+ <thead class="text-xs leading-tight text-gray-500 uppercase align-bottom">
26
+ <tr>
27
+ <th class="px-2 pb-2 text-base font-bold text-gray-800 normal-case border-b border-gray-400 whitespace-nowrap">Time</th>
28
+ <% stream_names.each_with_index do |stream_name, column| -%>
29
+ <th class="font-bold border-b border-gray-400 pb-2 px-2 normal-case text-base text-gray-800<%= " border-l" if column.positive? %>">
30
+ <div class="flex items-center justify-between gap-2">
31
+ <span class="break-words"><%= h(stream_name) %></span>
32
+ <% if stream_names.size > 1 -%>
33
+ <a class="text-xl leading-none text-gray-400 no-underline hover:text-red-700" href="<%= urls.swimlane_url(stream_names - [stream_name], sort) %>" title="Remove from comparison">&times;</a>
34
+ <% end -%>
35
+ </div>
36
+ </th>
37
+ <% end -%>
38
+ </tr>
39
+ </thead>
40
+ <tbody data-swimlane-view-target="tbody">
41
+ <%= render("swimlane/_rows", urls: urls, stream_names: stream_names, events: events, sort: sort) %>
42
+ </tbody>
43
+ </table>
44
+ </div>
45
+ <% end -%>
46
+ </div>
47
+ <dialog data-swimlane-add-target="dialog" class="open:flex flex-col items-center justify-center fixed inset-0 size-full max-w-full max-h-full bg-transparent backdrop:bg-black/50">
48
+ <button class="fixed inset-0 -z-10 cursor-default" data-action="swimlane-add#close" tabindex="-1" aria-label="Close"></button>
49
+ <form data-action="swimlane-add#go" class="w-full max-w-md p-4 bg-white rounded-lg shadow-xl">
50
+ <div class="relative">
51
+ <input data-swimlane-add-target="input" class="w-full px-3 py-2 text-gray-800 rounded outline-none appearance-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50" placeholder="Add stream to compare&hellip;" autofocus>
52
+ </div>
53
+ </form>
54
+ </dialog>
55
+ </div>
@@ -1,18 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module RubyEventStore
4
6
  module Browser
5
7
  PAGE_SIZE = 20
6
8
  SERIALIZED_GLOBAL_STREAM_NAME = "all".freeze
7
9
  DEFAULT_RELATED_STREAMS_QUERY = ->(stream_name) { [] }
10
+
11
+ ASSETS_ROOT = File.expand_path("../../public", __dir__).freeze
12
+
13
+ def self.fingerprint(name)
14
+ base, ext = name.split(".", 2)
15
+ digest = Digest::MD5.hexdigest(File.binread(File.join(ASSETS_ROOT, name)))[0, 8]
16
+ "#{base}.#{ext}?v=#{digest}"
17
+ end
18
+
19
+ BROWSER_JS = fingerprint("ruby_event_store_browser.js")
20
+ BROWSER_CSS = fingerprint("ruby_event_store_browser.css")
8
21
  end
9
22
  end
10
23
 
11
- require_relative "browser/get_event"
12
- require_relative "browser/json_api_event"
13
- require_relative "browser/json_api_stream"
14
24
  require_relative "browser/get_events_from_stream"
15
- require_relative "browser/get_stream"
25
+ require_relative "browser/get_events_from_streams"
16
26
  require_relative "browser/urls"
17
- require_relative "browser/gem_source"
18
27
  require_relative "browser/router"
28
+ require_relative "browser/renderer"
29
+ require_relative "browser/layout"
30
+ require_relative "browser/extension_context"