paper_view 0.1.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 83879cfa36b7725f33b545fff5fe55fb4903b8a787f279e84bb90493ba282955
4
- data.tar.gz: 44bcabbe8a90f4c9c162ea78531677cec55855f1d89421fb8b930f1b2ee384d4
3
+ metadata.gz: e5b8c10c7eacd5de4647ef283af9a141770cdee45592572ea0c5b690991f3990
4
+ data.tar.gz: c245d622bd0061bba67a1ef574938d83c83c01717688efd8ecede618bcb02b43
5
5
  SHA512:
6
- metadata.gz: dc43978efac30d7baad54ed0b092ef53f4c61f7ed5c5cc854b2a2e65ce833739f7950d610ea02f3da3b526307d01f942fcf16dbed8b16aefbeac094151cb6f4c
7
- data.tar.gz: ffd83f028d936b9f24c6ee8855d142249fd2be76f75bc68f6e98cf8afd141eab5686759e58def65be00d9c744ac26b72ce589c68c21525709c3a4095dc5c70a2
6
+ metadata.gz: baaecb34237b30125aa4cb6d082241336ec38cb8ec76e7292e15f4d0185ba26c56ad0e32a99b925fd96350064e18ae408d2b7e2ed7ce4271d1c25e9f954b6dca
7
+ data.tar.gz: d15fdc166fe827652b429666ef5fe1ea4eb8189edf33a0a7428eff19bd95c5ecf8ea8e1eeafb066a28b0bde9cebcbcdad2f4c82e012b96a391c1aa9431f9ee6d
data/README.md CHANGED
@@ -47,6 +47,10 @@ PaperView.setup do |config|
47
47
 
48
48
  config.per_page = 25
49
49
 
50
+ # The exact columns to show as metadata. Defaults to every column that
51
+ # paper_trail does not write itself, so usually nothing has to be set here.
52
+ # config.metadata_columns = %w[ip user_agent]
53
+
50
54
  # Any model with the paper_trail column layout works here.
51
55
  # Required interface: id, item_type, item_id, event, whodunnit, created_at, object_changes
52
56
  config.version_class_name = "PaperTrail::Version"
@@ -55,6 +59,24 @@ end
55
59
 
56
60
  Visit `/paper_view`.
57
61
 
62
+ ## Metadata
63
+
64
+ paper_trail can store [extra columns](https://github.com/paper-trail-gem/paper_trail#4c-storing-metadata) on a
65
+ version, either through `has_paper_trail meta: { ... }` or through `controller_info`:
66
+
67
+ ```ruby
68
+ class Post < ApplicationRecord
69
+ has_paper_trail meta: {author_id: :author_id}
70
+ end
71
+ ```
72
+
73
+ Every column of the `versions` table counts as metadata, except the ones paper_trail writes itself:
74
+ `id`, `item_type`, `item_subtype`, `item_id`, `event`, `whodunnit`, `object`, `object_changes`,
75
+ `created_at`, `updated_at` and `transaction_id`.
76
+
77
+ Set `config.metadata_columns` to the exact list you want if your table carries columns you would
78
+ rather not see — anything left out of that list is then hidden.
79
+
58
80
  ## PaperTrail setup
59
81
 
60
82
  To work properly, PaperView requires an `object_changes` column in your `versions` table. This can be added by running the installation generator with the `--with-changes` option. This can be done even if you already have a `versions` table; it will generate a migration that adds the `object_changes` column to your existing `versions` table:
@@ -0,0 +1,12 @@
1
+ module PaperView
2
+ class StatsController < ApplicationController
3
+ def show
4
+ # First request shows skeletons.
5
+ # Second request comes with load_content param
6
+ return unless params[:load_content]
7
+
8
+ @stats = TableStats.new
9
+ render partial: "stats", locals: {stats: @stats}, layout: false if request.xhr?
10
+ end
11
+ end
12
+ end
@@ -14,6 +14,16 @@ module PaperView
14
14
  tag.time(time.strftime(PaperView.config.time_format), datetime: time.iso8601, title: time.iso8601)
15
15
  end
16
16
 
17
+ def paper_view_bytes(bytes)
18
+ return EMPTY if bytes.nil?
19
+
20
+ number_to_human_size(bytes, precision: 3, significant: true, strip_insignificant_zeros: true)
21
+ end
22
+
23
+ def paper_view_percentage(share)
24
+ number_to_percentage(share * 100, precision: 1, significant: false, strip_insignificant_zeros: false)
25
+ end
26
+
17
27
  def paper_view_item_path(version)
18
28
  versions_path(item_type: version.item_type, item_id: version.item_id)
19
29
  end
@@ -27,6 +37,22 @@ module PaperView
27
37
  safe_join(paper_view_filter_params.except(*excluded).map { |name, value| hidden_field_tag(name, value, id: nil) })
28
38
  end
29
39
 
40
+ def paper_view_nav_class(active)
41
+ active ? "pv-header__link pv-header__link--active" : "pv-header__link"
42
+ end
43
+
44
+ def paper_view_turbo_script_tag
45
+ return unless PaperView.config.turbo
46
+
47
+ javascript_include_tag(turbo_asset_path(digest: PaperView::TurboAsset::DIGEST), type: "module", nonce: true)
48
+ end
49
+
50
+ # A Turbo visit from the host app would swap our markup and styles into its document.
51
+ # Our own Turbo announces itself, so every other visit is sent through a full reload.
52
+ def paper_view_full_reload_required?
53
+ request.headers["X-PaperView-Turbo"].blank?
54
+ end
55
+
30
56
  def paper_view_nonce_attribute
31
57
  nonce = content_security_policy_nonce
32
58
  return unless nonce
@@ -0,0 +1,73 @@
1
+ module PaperView
2
+ class Metadata
3
+ include Enumerable
4
+
5
+ # Everything paper_trail itself writes. Whatever else the table carries was put
6
+ # there by `has_paper_trail meta:` or `controller_info`, which is the metadata.
7
+ PAPER_TRAIL_COLUMNS = %w[
8
+ id item_type item_subtype item_id event whodunnit
9
+ object object_changes created_at updated_at transaction_id
10
+ ].freeze
11
+
12
+ VISIBLE = 2
13
+
14
+ Entry = Struct.new(:name, :value) do
15
+ def text
16
+ AttributeChange.display(value)
17
+ end
18
+ end
19
+
20
+ def self.for(version)
21
+ new(version, columns(version))
22
+ end
23
+
24
+ def self.columns(version)
25
+ configured = PaperView.config.metadata_columns
26
+ return Array(configured).map(&:to_s) if configured
27
+
28
+ table_columns(version) - PAPER_TRAIL_COLUMNS
29
+ end
30
+
31
+ def self.table_columns(version)
32
+ version.class.respond_to?(:column_names) ? version.class.column_names.map(&:to_s) : []
33
+ end
34
+
35
+ attr_reader :entries
36
+
37
+ def initialize(version, columns)
38
+ @entries = columns.filter_map do |name|
39
+ value = read(version, name)
40
+ Entry.new(name, value) unless blank?(value)
41
+ end
42
+ end
43
+
44
+ def each(&block)
45
+ entries.each(&block)
46
+ end
47
+
48
+ def empty?
49
+ entries.empty?
50
+ end
51
+
52
+ def visible
53
+ entries.first(VISIBLE)
54
+ end
55
+
56
+ def hidden
57
+ entries.drop(VISIBLE)
58
+ end
59
+
60
+ private
61
+
62
+ def read(version, name)
63
+ version.public_send(name) if version.respond_to?(name)
64
+ rescue
65
+ nil
66
+ end
67
+
68
+ def blank?(value)
69
+ return true if value.nil?
70
+ value.respond_to?(:empty?) ? value.empty? : false
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,107 @@
1
+ module PaperView
2
+ # Estimates how many bytes of payload each item type keeps in the versions table.
3
+ #
4
+ # Both paths read a bounded slice instead of the whole table: PostgreSQL samples pages
5
+ # from across the table, every other adapter reads the newest rows.
6
+ class PayloadSizes
7
+ COLUMNS = %w[object object_changes].freeze
8
+ SAMPLE_PAGES = 4_000
9
+ SAMPLE_ROWS = 5_000
10
+ # Fixed so that the same pages come back every time: an estimate that changes
11
+ # on every refresh reads like a bug, and a new sample is no more true than the last.
12
+ SAMPLE_SEED = 1
13
+
14
+ def initialize(versions_table)
15
+ @versions_table = versions_table
16
+ end
17
+
18
+ def measurable?
19
+ average_bytes.any?
20
+ end
21
+
22
+ def average_bytes
23
+ return @average_bytes if defined?(@average_bytes)
24
+ return @average_bytes = {} if columns.empty?
25
+
26
+ @average_bytes = averages(versions_table.postgresql? ? page_sample : head_sample)
27
+ rescue ActiveRecord::StatementInvalid
28
+ @average_bytes = {}
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :versions_table
34
+
35
+ def columns
36
+ @columns ||= COLUMNS & version_class.column_names
37
+ end
38
+
39
+ # Item types too rare to land in the sample fall back to the overall average, so that
40
+ # their bytes still count towards the total instead of quietly going missing.
41
+ def averages(sample)
42
+ rows = sample.sum { |_, count, _| count }
43
+ return {} if rows.zero?
44
+
45
+ overall = sample.sum { |_, _, bytes| bytes } / rows
46
+ sample.each_with_object(Hash.new(overall)) { |(item_type, count, bytes), averages| averages[item_type] = bytes / count }
47
+ end
48
+
49
+ def page_sample
50
+ select_sample(<<~SQL)
51
+ SELECT #{item_type_column}, COUNT(*), COALESCE(SUM(#{payload_size}), 0)
52
+ FROM #{version_class.quoted_table_name} TABLESAMPLE SYSTEM (#{sample_percent}) REPEATABLE (#{SAMPLE_SEED})
53
+ GROUP BY #{item_type_column}
54
+ SQL
55
+ end
56
+
57
+ # Payloads grow as models gain columns, so the newest rows are the ones that
58
+ # say what the table weighs now.
59
+ def head_sample
60
+ newest = version_class.select(item_type_column, Arel.sql("#{payload_size} AS pv_bytes")).limit(SAMPLE_ROWS)
61
+ newest = newest.order(version_class.primary_key => :desc) if version_class.primary_key
62
+
63
+ select_sample(<<~SQL)
64
+ SELECT #{item_type_column}, COUNT(*), COALESCE(SUM(pv_bytes), 0)
65
+ FROM (#{newest.to_sql}) pv_sample
66
+ GROUP BY #{item_type_column}
67
+ SQL
68
+ end
69
+
70
+ def select_sample(sql)
71
+ connection.select_rows(sql, "PaperView Payload Sample").map { |item_type, count, bytes| [item_type, count.to_i, bytes.to_f] }
72
+ end
73
+
74
+ # A fixed percentage reads more the bigger the table gets, which is backwards for a
75
+ # page nobody wants to wait on. Aim at a page count and let the percentage follow.
76
+ def sample_percent
77
+ pages = versions_table.heap_pages
78
+ return 100 if pages <= SAMPLE_PAGES
79
+
80
+ (SAMPLE_PAGES * 100.0 / pages).ceil(4)
81
+ end
82
+
83
+ def payload_size
84
+ @payload_size ||= columns.map { |column| "COALESCE(#{byte_length(connection.quote_column_name(column))}, 0)" }.join(" + ")
85
+ end
86
+
87
+ # Reports the stored size, so a compressed or out-of-line payload is counted as it sits on disk.
88
+ def byte_length(column)
89
+ return "pg_column_size(#{column})" if versions_table.postgresql?
90
+ return "LENGTH(CAST(#{column} AS BLOB))" if versions_table.sqlite?
91
+
92
+ "LENGTH(#{column})"
93
+ end
94
+
95
+ def item_type_column
96
+ connection.quote_column_name("item_type")
97
+ end
98
+
99
+ def version_class
100
+ PaperView.version_class
101
+ end
102
+
103
+ def connection
104
+ version_class.connection
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,62 @@
1
+ module PaperView
2
+ class TableStats
3
+ Row = Struct.new(:item_type, :count, :average_bytes) do
4
+ def estimated_bytes
5
+ return nil if average_bytes.nil?
6
+
7
+ (average_bytes * count).round
8
+ end
9
+
10
+ def share(total)
11
+ return 0.0 if total.nil? || total.zero?
12
+
13
+ estimated_bytes.to_i / total.to_f
14
+ end
15
+ end
16
+
17
+ def rows
18
+ @rows ||= counts.map { |item_type, count| Row.new(item_type, count, payload_sizes.average_bytes[item_type]) }
19
+ .sort_by { |row| [-row.estimated_bytes.to_i, -row.count] }
20
+ end
21
+
22
+ def empty?
23
+ rows.empty?
24
+ end
25
+
26
+ def total_count
27
+ @total_count ||= rows.sum(&:count)
28
+ end
29
+
30
+ def estimated_total_bytes
31
+ return nil unless measurable?
32
+
33
+ @estimated_total_bytes ||= rows.sum { |row| row.estimated_bytes.to_i }
34
+ end
35
+
36
+ def disk_size
37
+ versions_table.disk_size
38
+ end
39
+
40
+ private
41
+
42
+ def measurable?
43
+ payload_sizes.measurable?
44
+ end
45
+
46
+ def counts
47
+ @counts ||= version_class.group(:item_type).count
48
+ end
49
+
50
+ def payload_sizes
51
+ @payload_sizes ||= PayloadSizes.new(versions_table)
52
+ end
53
+
54
+ def versions_table
55
+ @versions_table ||= VersionsTable.new
56
+ end
57
+
58
+ def version_class
59
+ PaperView.version_class
60
+ end
61
+ end
62
+ end
@@ -34,6 +34,10 @@ module PaperView
34
34
  [version.item_type, version.item_id]
35
35
  end
36
36
 
37
+ def metadata
38
+ @metadata ||= Metadata.for(version)
39
+ end
40
+
37
41
  def burst?
38
42
  burst_size > 1
39
43
  end
@@ -1,5 +1,7 @@
1
1
  module PaperView
2
2
  class VersionsTable
3
+ DiskSize = Struct.new(:table, :indexes)
4
+
3
5
  def item_types
4
6
  return distinct_scan unless postgresql? && item_type_indexed?
5
7
 
@@ -12,6 +14,35 @@ module PaperView
12
14
  @item_type_indexed = connection.indexes(version_class.table_name).any? { |index| Array(index.columns).first == "item_type" }
13
15
  end
14
16
 
17
+ def disk_size
18
+ return @disk_size if defined?(@disk_size)
19
+
20
+ sql = disk_size_sql
21
+ row = sql && connection.select_rows(sql).first
22
+ @disk_size = row && DiskSize.new(*row.map(&:to_i))
23
+ rescue ActiveRecord::StatementInvalid
24
+ @disk_size = nil
25
+ end
26
+
27
+ def heap_pages
28
+ connection.select_value(<<~SQL).to_i
29
+ #{partition_tree_cte}
30
+ SELECT COALESCE(SUM(pg_relation_size(oid)), 0) / current_setting('block_size')::bigint FROM pv_tree
31
+ SQL
32
+ end
33
+
34
+ def postgresql?
35
+ connection.adapter_name.match?(/postgres/i)
36
+ end
37
+
38
+ def mysql?
39
+ connection.adapter_name.match?(/mysql|trilogy|maria/i)
40
+ end
41
+
42
+ def sqlite?
43
+ connection.adapter_name.match?(/sqlite/i)
44
+ end
45
+
15
46
  private
16
47
 
17
48
  def version_class
@@ -22,14 +53,39 @@ module PaperView
22
53
  version_class.connection
23
54
  end
24
55
 
25
- def postgresql?
26
- connection.adapter_name.match?(/postgres/i)
27
- end
28
-
29
56
  def distinct_scan
30
57
  version_class.distinct.order(:item_type).pluck(:item_type)
31
58
  end
32
59
 
60
+ def disk_size_sql
61
+ if postgresql?
62
+ <<~SQL
63
+ #{partition_tree_cte}
64
+ SELECT COALESCE(SUM(pg_table_size(oid)), 0), COALESCE(SUM(pg_indexes_size(oid)), 0) FROM pv_tree
65
+ SQL
66
+ elsif mysql?
67
+ <<~SQL
68
+ SELECT data_length, index_length
69
+ FROM information_schema.tables
70
+ WHERE table_schema = DATABASE() AND table_name = #{connection.quote(version_class.table_name)}
71
+ SQL
72
+ end
73
+ end
74
+
75
+ # A partitioned table keeps no rows of its own, so every size it reports is zero
76
+ # until the partitions underneath it are counted too.
77
+ def partition_tree_cte
78
+ relation = "#{connection.quote(version_class.quoted_table_name)}::regclass"
79
+
80
+ <<~SQL
81
+ WITH RECURSIVE pv_tree AS (
82
+ SELECT #{relation} AS oid
83
+ UNION ALL
84
+ SELECT partition.inhrelid FROM pg_inherits partition JOIN pv_tree ON partition.inhparent = pv_tree.oid
85
+ )
86
+ SQL
87
+ end
88
+
33
89
  # A plain DISTINCT reads every row. This walks the item_type index
34
90
  # and jumps straight from one item_type to the next one.
35
91
  def skip_scan_sql
@@ -4,7 +4,10 @@
4
4
  <head>
5
5
  <meta charset="utf-8">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1">
7
- <meta name="turbo-visit-control" content="reload">
7
+ <% if paper_view_full_reload_required? %>
8
+ <meta name="turbo-visit-control" content="reload">
9
+ <% end %>
10
+ <meta name="turbo-cache-control" content="no-preview">
8
11
  <meta name="robots" content="noindex, nofollow">
9
12
 
10
13
  <title>PaperView</title>
@@ -12,6 +15,7 @@
12
15
  <%= csrf_meta_tags %>
13
16
  <%= csp_meta_tag %>
14
17
  <%= render "paper_view/shared/styles" %>
18
+ <%= paper_view_turbo_script_tag %>
15
19
  </head>
16
20
 
17
21
  <body>
@@ -20,8 +24,13 @@
20
24
  PaperView
21
25
  <% end %>
22
26
 
27
+ <nav class="pv-header__nav">
28
+ <%= link_to "Versions", root_path, class: paper_view_nav_class(controller_name == "versions") %>
29
+ <%= link_to "Stats", stats_path, class: paper_view_nav_class(controller_name == "stats") %>
30
+ </nav>
31
+
23
32
  <% if main_app.respond_to?(:root_path) %>
24
- <span class="pv-header__meta"><%= link_to "← Back to app", main_app.root_path %></span>
33
+ <span class="pv-header__meta"><%= link_to "← Back to app", main_app.root_path, data: {turbo: false} %></span>
25
34
  <% end %>
26
35
  </header>
27
36