paper_view 0.1.0 → 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 +4 -4
- data/README.md +34 -2
- data/app/controllers/paper_view/stats_controller.rb +12 -0
- data/app/helpers/paper_view/application_helper.rb +26 -0
- data/app/models/paper_view/metadata.rb +73 -0
- data/app/models/paper_view/payload_sizes.rb +107 -0
- data/app/models/paper_view/table_stats.rb +62 -0
- data/app/models/paper_view/timeline.rb +2 -2
- data/app/models/paper_view/version_row.rb +27 -5
- data/app/models/paper_view/versions_table.rb +60 -4
- data/app/views/layouts/paper_view/application.html.erb +12 -4
- data/app/views/paper_view/shared/_script.html.erb +174 -70
- data/app/views/paper_view/shared/_styles.html.erb +113 -19
- data/app/views/paper_view/stats/_stats.html.erb +84 -0
- data/app/views/paper_view/stats/show.html.erb +21 -0
- data/app/views/paper_view/versions/_filters.html.erb +1 -1
- data/app/views/paper_view/versions/_footer.html.erb +1 -1
- data/app/views/paper_view/versions/_panel.html.erb +91 -44
- data/app/views/paper_view/versions/_timeline_row.html.erb +6 -6
- data/app/views/paper_view/versions/index.html.erb +4 -4
- data/config/routes.rb +3 -0
- data/lib/paper_view/assets/turbo.js +7256 -0
- data/lib/paper_view/configuration.rb +4 -1
- data/lib/paper_view/turbo_asset.rb +21 -0
- data/lib/paper_view/version.rb +1 -1
- data/lib/paper_view.rb +1 -0
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5b8c10c7eacd5de4647ef283af9a141770cdee45592572ea0c5b690991f3990
|
|
4
|
+
data.tar.gz: c245d622bd0061bba67a1ef574938d83c83c01717688efd8ecede618bcb02b43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: baaecb34237b30125aa4cb6d082241336ec38cb8ec76e7292e15f4d0185ba26c56ad0e32a99b925fd96350064e18ae408d2b7e2ed7ce4271d1c25e9f954b6dca
|
|
7
|
+
data.tar.gz: d15fdc166fe827652b429666ef5fe1ea4eb8189edf33a0a7428eff19bd95c5ecf8ea8e1eeafb066a28b0bde9cebcbcdad2f4c82e012b96a391c1aa9431f9ee6d
|
data/README.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# PaperView
|
|
2
2
|
|
|
3
3
|
A lightweight, mountable dashboard for [paper_trail](https://github.com/paper-trail-gem/paper_trail).
|
|
4
|
-
Browse the `versions` table and read, in
|
|
4
|
+
Browse the `versions` table and read, in a chronological timeline, what actually changed.
|
|
5
|
+
|
|
6
|
+

|
|
5
7
|
|
|
6
8
|
## Installation
|
|
7
9
|
|
|
@@ -18,7 +20,7 @@ gem "paper_view"
|
|
|
18
20
|
# config/routes.rb
|
|
19
21
|
Rails.application.routes.draw do
|
|
20
22
|
authenticate :user, ->(user) { user.admin? } do
|
|
21
|
-
mount PaperView::Engine => "/
|
|
23
|
+
mount PaperView::Engine => "/versions"
|
|
22
24
|
end
|
|
23
25
|
end
|
|
24
26
|
```
|
|
@@ -45,6 +47,10 @@ PaperView.setup do |config|
|
|
|
45
47
|
|
|
46
48
|
config.per_page = 25
|
|
47
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
|
+
|
|
48
54
|
# Any model with the paper_trail column layout works here.
|
|
49
55
|
# Required interface: id, item_type, item_id, event, whodunnit, created_at, object_changes
|
|
50
56
|
config.version_class_name = "PaperTrail::Version"
|
|
@@ -52,3 +58,29 @@ end
|
|
|
52
58
|
```
|
|
53
59
|
|
|
54
60
|
Visit `/paper_view`.
|
|
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
|
+
|
|
80
|
+
## PaperTrail setup
|
|
81
|
+
|
|
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:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
rails generate paper_trail:install --with-changes
|
|
86
|
+
```
|
|
@@ -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
|
|
@@ -23,12 +23,12 @@ module PaperView
|
|
|
23
23
|
@items ||= rows.map(&:item_key).uniq
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def
|
|
26
|
+
def multi_item_view?
|
|
27
27
|
items.size != 1
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def title
|
|
31
|
-
|
|
31
|
+
multi_item_view? ? "All changes" : rows.first.item_label
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def selected
|
|
@@ -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
|
|
@@ -42,17 +46,37 @@ module PaperView
|
|
|
42
46
|
"#{burst_size} versions within 1s"
|
|
43
47
|
end
|
|
44
48
|
|
|
49
|
+
def created?
|
|
50
|
+
event == "create"
|
|
51
|
+
end
|
|
52
|
+
|
|
45
53
|
def destroyed?
|
|
46
54
|
event == "destroy"
|
|
47
55
|
end
|
|
48
56
|
|
|
57
|
+
def snapshot?
|
|
58
|
+
created? || destroyed?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def change_label
|
|
62
|
+
noun = case event
|
|
63
|
+
when "create" then "initial value"
|
|
64
|
+
when "destroy" then "final value"
|
|
65
|
+
else "changed field"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
"#{change_set.size} #{noun.pluralize(change_set.size)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
49
71
|
def lines
|
|
50
|
-
leaves.first(MAX_LINES)
|
|
72
|
+
snapshot? ? [] : leaves.first(MAX_LINES)
|
|
51
73
|
end
|
|
52
74
|
|
|
53
75
|
def footnotes
|
|
54
76
|
notes = []
|
|
55
77
|
notes << "payload not deserializable" if change_set.unreadable?
|
|
78
|
+
return notes if snapshot?
|
|
79
|
+
|
|
56
80
|
notes << "..." if overflow_count.positive?
|
|
57
81
|
notes << "no attribute changes recorded" if leaves.empty? && !change_set.unreadable?
|
|
58
82
|
notes
|
|
@@ -81,14 +105,12 @@ module PaperView
|
|
|
81
105
|
version = #{PaperView.config.version_class_name}.find(#{id})
|
|
82
106
|
item = version.reify # the deleted record
|
|
83
107
|
|
|
84
|
-
item.
|
|
85
|
-
|
|
86
|
-
# item.save! # re-create the record
|
|
108
|
+
# item.save! # re-create the record; columns added since are nil
|
|
87
109
|
RUBY
|
|
88
110
|
end
|
|
89
111
|
|
|
90
112
|
def leaves
|
|
91
|
-
@leaves ||=
|
|
113
|
+
@leaves ||= change_set.flat_map(&:leaves)
|
|
92
114
|
end
|
|
93
115
|
|
|
94
116
|
def overflow_count
|
|
@@ -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,25 +4,33 @@
|
|
|
4
4
|
<head>
|
|
5
5
|
<meta charset="utf-8">
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
-
|
|
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>
|
|
11
|
-
<%= tag.link rel: "icon", href: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 100 100"><text y=".90em" font-size="90"
|
|
14
|
+
<%= tag.link rel: "icon", href: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 100 100"><text y=".90em" font-size="90">📝</text></svg>' %>
|
|
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>
|
|
18
22
|
<header class="pv-header">
|
|
19
23
|
<%= link_to root_path, class: "pv-header__brand" do %>
|
|
20
|
-
<span class="pv-header__dot"></span>
|
|
21
24
|
PaperView
|
|
22
25
|
<% end %>
|
|
23
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
|
+
|
|
24
32
|
<% if main_app.respond_to?(:root_path) %>
|
|
25
|
-
<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>
|
|
26
34
|
<% end %>
|
|
27
35
|
</header>
|
|
28
36
|
|