rails_audit_log 0.8.0 → 0.9.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 +10 -0
  3. data/app/assets/stylesheets/rails_audit_log/_01_base.css +32 -0
  4. data/app/assets/stylesheets/rails_audit_log/_02_layout.css +34 -0
  5. data/app/assets/stylesheets/rails_audit_log/_03_table.css +39 -0
  6. data/app/assets/stylesheets/rails_audit_log/_04_badges.css +13 -0
  7. data/app/assets/stylesheets/rails_audit_log/_05_diff.css +94 -0
  8. data/app/assets/stylesheets/rails_audit_log/_06_timeline.css +21 -0
  9. data/app/assets/stylesheets/rails_audit_log/_07_detail.css +15 -0
  10. data/app/assets/stylesheets/rails_audit_log/_08_pagination.css +56 -0
  11. data/app/assets/stylesheets/rails_audit_log/_09_filters.css +54 -0
  12. data/app/assets/stylesheets/rails_audit_log/application.css +1 -15
  13. data/app/controllers/rails_audit_log/application_controller.rb +12 -0
  14. data/app/controllers/rails_audit_log/audit_log_entries_controller.rb +31 -0
  15. data/app/controllers/rails_audit_log/resources_controller.rb +13 -0
  16. data/app/helpers/rails_audit_log/application_helper.rb +13 -0
  17. data/app/javascript/rails_audit_log/application.js +8 -0
  18. data/app/javascript/rails_audit_log/diff_controller.js +20 -0
  19. data/app/javascript/rails_audit_log/search_controller.js +12 -0
  20. data/app/models/rails_audit_log/audit_log_entry.rb +5 -3
  21. data/app/views/layouts/rails_audit_log/application.html.erb +16 -10
  22. data/app/views/rails_audit_log/audit_log_entries/_diff.html.erb +55 -0
  23. data/app/views/rails_audit_log/audit_log_entries/index.html.erb +76 -0
  24. data/app/views/rails_audit_log/audit_log_entries/show.html.erb +50 -0
  25. data/app/views/rails_audit_log/resources/show.html.erb +35 -0
  26. data/config/importmap.rb +5 -0
  27. data/config/routes.rb +3 -0
  28. data/lib/generators/rails_audit_log/initializer/templates/rails_audit_log.rb +10 -0
  29. data/lib/rails_audit_log/engine.rb +28 -0
  30. data/lib/rails_audit_log/version.rb +1 -1
  31. data/lib/rails_audit_log.rb +6 -0
  32. metadata +62 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5cd71899d109aa978910a72ef144aeafb601dc70790e044ef723b49d1d560f65
4
- data.tar.gz: 8884deec2a0abf63f14639794d6ac614689c9799e09b375ffc0cc7d788943e7d
3
+ metadata.gz: 9c9a9fcdb866f5300a3a2fd9bd6026ad93530d15b0b26d771ef1a28a47985280
4
+ data.tar.gz: 01ac4b3f95fd1c2067a8aa5936122ff86dc264fe771881515dd31d3401377b66
5
5
  SHA512:
6
- metadata.gz: c32ada5189eda30461ea1dbc10a67982fb0ff6c4244da4339aa3376082b38a5272bb3a1b91fe50110c08ae9ee0ae3ede52d2a43701c0f7260a4d5a6581c0f42d
7
- data.tar.gz: 448becbb80428dd754bca4d1ab43625de5ec85a2c9d712e9e4a28f7c14b0c18ce6865b25dfd9ea6c46f7b1814988d0eea77ae8b6aee7a230927cc37a80f60cf3
6
+ metadata.gz: 4c155747f1219996a6fde0fcc0d00c764df0f296c7b099418b7020ef90984690400300174b5c1cc1dc4842451a3f53d35e4b710542693683c78d4706c0eca1c1
7
+ data.tar.gz: 256ef73b739193443718481ef7fa1c0920ae4b4d424e3466b9ac3dbaf9a0bf8b7cf4823d0fd7d000652710985ac60fa6df6252e5997266bb40eda6bde9ba9941
data/README.md CHANGED
@@ -31,6 +31,16 @@ bin/rails generate rails_audit_log:initializer
31
31
 
32
32
  This creates `config/initializers/rails_audit_log.rb` with all settings documented as commented examples inside a `RailsAuditLog.configure` block.
33
33
 
34
+ ## Web dashboard
35
+
36
+ Mount the engine in `config/routes.rb` to enable the built-in audit trail browser:
37
+
38
+ ```ruby
39
+ mount RailsAuditLog::Engine, at: "/audit"
40
+ ```
41
+
42
+ Then visit `/audit` to browse all audit entries. The dashboard is delivered via propshaft, importmaps, and CDN-pinned Turbo and Stimulus — no asset pipeline configuration required in the host app.
43
+
34
44
  ## Usage
35
45
 
36
46
  ### Tracking a model
@@ -0,0 +1,32 @@
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ :root {
4
+ --ral-bg: #f5f5f5;
5
+ --ral-surface: #ffffff;
6
+ --ral-border: #e5e5e5;
7
+ --ral-border-subtle: #f0f0f0;
8
+ --ral-text: #1a1a1a;
9
+ --ral-muted: #666666;
10
+ --ral-primary: #1d4ed8;
11
+ --ral-danger: #991b1b;
12
+ --ral-success: #166534;
13
+ --ral-radius: 6px;
14
+ --ral-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
15
+ --ral-surface-hover: #fafafa;
16
+ }
17
+
18
+ body {
19
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
20
+ font-size: 14px;
21
+ line-height: 1.5;
22
+ color: var(--ral-text);
23
+ background: var(--ral-bg);
24
+ min-height: 100vh;
25
+ }
26
+
27
+ .ral-link { color: var(--ral-primary); text-decoration: none; }
28
+ .ral-link:hover { text-decoration: underline; }
29
+
30
+ .ral-muted { color: var(--ral-muted); font-size: 13px; }
31
+ .ral-timestamp { color: var(--ral-muted); font-size: 13px; white-space: nowrap; }
32
+ .ral-reason { font-size: 12px; color: var(--ral-muted); font-style: italic; }
@@ -0,0 +1,34 @@
1
+ .ral-header {
2
+ background: var(--ral-text);
3
+ color: #fff;
4
+ height: 48px;
5
+ display: flex;
6
+ align-items: center;
7
+ padding: 0 24px;
8
+ }
9
+
10
+ .ral-header__inner {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 16px;
14
+ width: 100%;
15
+ max-width: 1200px;
16
+ margin: 0 auto;
17
+ }
18
+
19
+ .ral-header__logo {
20
+ color: #fff;
21
+ text-decoration: none;
22
+ font-weight: 600;
23
+ font-size: 15px;
24
+ }
25
+ .ral-header__logo:hover { opacity: 0.8; }
26
+
27
+ .ral-main { max-width: 1200px; margin: 0 auto; padding: 24px; }
28
+
29
+ .ral-page-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; }
30
+ .ral-page-header__title { font-size: 20px; font-weight: 600; }
31
+ .ral-page-header__meta { font-size: 13px; color: var(--ral-muted); }
32
+
33
+ .ral-breadcrumb { font-size: 12px; color: var(--ral-muted); margin-bottom: 4px; }
34
+ .ral-section-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: #333; }
@@ -0,0 +1,39 @@
1
+ .ral-table-wrap {
2
+ background: var(--ral-surface);
3
+ border: 1px solid var(--ral-border);
4
+ border-radius: var(--ral-radius);
5
+ overflow: hidden;
6
+ box-shadow: var(--ral-shadow);
7
+ }
8
+
9
+ .ral-table { width: 100%; border-collapse: collapse; }
10
+
11
+ .ral-table th {
12
+ background: var(--ral-bg);
13
+ padding: 10px 16px;
14
+ text-align: left;
15
+ font-size: 12px;
16
+ font-weight: 600;
17
+ text-transform: uppercase;
18
+ letter-spacing: 0.04em;
19
+ color: var(--ral-muted);
20
+ border-bottom: 1px solid var(--ral-border);
21
+ }
22
+
23
+ .ral-table td {
24
+ padding: 10px 16px;
25
+ border-bottom: 1px solid var(--ral-border-subtle);
26
+ vertical-align: middle;
27
+ }
28
+
29
+ .ral-table tbody tr:last-child td { border-bottom: none; }
30
+ .ral-table tbody tr:hover { background: var(--ral-surface-hover); }
31
+
32
+ .ral-empty {
33
+ background: var(--ral-surface);
34
+ border: 1px solid var(--ral-border);
35
+ border-radius: var(--ral-radius);
36
+ padding: 40px;
37
+ text-align: center;
38
+ color: var(--ral-muted);
39
+ }
@@ -0,0 +1,13 @@
1
+ .ral-badge {
2
+ display: inline-block;
3
+ padding: 2px 8px;
4
+ border-radius: 10px;
5
+ font-size: 11px;
6
+ font-weight: 600;
7
+ text-transform: uppercase;
8
+ letter-spacing: 0.04em;
9
+ }
10
+
11
+ .ral-badge--create { background: #dcfce7; color: var(--ral-success); }
12
+ .ral-badge--update { background: #dbeafe; color: var(--ral-primary); }
13
+ .ral-badge--destroy { background: #fee2e2; color: var(--ral-danger); }
@@ -0,0 +1,94 @@
1
+ /* Toggle */
2
+ .ral-diff-toggle {
3
+ display: flex;
4
+ border: 1px solid var(--ral-border);
5
+ border-radius: var(--ral-radius);
6
+ overflow: hidden;
7
+ width: fit-content;
8
+ margin-bottom: 12px;
9
+ }
10
+
11
+ .ral-diff-btn {
12
+ padding: 4px 12px;
13
+ font-size: 12px;
14
+ font-weight: 500;
15
+ color: var(--ral-muted);
16
+ background: var(--ral-surface);
17
+ border: none;
18
+ cursor: pointer;
19
+ }
20
+ .ral-diff-btn + .ral-diff-btn { border-left: 1px solid var(--ral-border); }
21
+ .ral-diff-btn:hover:not(.ral-diff-btn--active) { background: var(--ral-bg); color: var(--ral-text); }
22
+ .ral-diff-btn--active { background: var(--ral-primary); color: #fff; }
23
+
24
+ /* Inline table */
25
+ .ral-diff { width: 100%; border-collapse: collapse; font-size: 13px; }
26
+
27
+ .ral-diff th {
28
+ background: var(--ral-bg);
29
+ padding: 6px 12px;
30
+ text-align: left;
31
+ font-size: 11px;
32
+ font-weight: 600;
33
+ text-transform: uppercase;
34
+ letter-spacing: 0.04em;
35
+ color: var(--ral-muted);
36
+ border-bottom: 1px solid var(--ral-border);
37
+ }
38
+
39
+ .ral-diff td { padding: 6px 12px; border-bottom: 1px solid var(--ral-border-subtle); vertical-align: top; }
40
+ .ral-diff tbody tr:last-child td { border-bottom: none; }
41
+
42
+ .ral-diff__attr { font-weight: 500; color: #333; width: 160px; }
43
+ .ral-diff__before { color: var(--ral-danger); background: #fff5f5; font-family: monospace; }
44
+ .ral-diff__after { color: var(--ral-success); background: #f0fdf4; font-family: monospace; }
45
+
46
+ /* Side-by-side panels */
47
+ .ral-diff-side {
48
+ display: grid;
49
+ grid-template-columns: 1fr 1fr;
50
+ border: 1px solid var(--ral-border);
51
+ border-radius: var(--ral-radius);
52
+ overflow: hidden;
53
+ font-size: 13px;
54
+ }
55
+
56
+ .ral-diff-side__panel--before { border-right: 1px solid var(--ral-border); }
57
+
58
+ .ral-diff-side__header {
59
+ padding: 6px 12px;
60
+ font-size: 11px;
61
+ font-weight: 600;
62
+ text-transform: uppercase;
63
+ letter-spacing: 0.04em;
64
+ color: var(--ral-muted);
65
+ border-bottom: 1px solid var(--ral-border);
66
+ background: var(--ral-bg);
67
+ }
68
+
69
+ .ral-diff-side__row {
70
+ display: flex;
71
+ gap: 8px;
72
+ padding: 6px 12px;
73
+ border-bottom: 1px solid var(--ral-border-subtle);
74
+ align-items: baseline;
75
+ }
76
+ .ral-diff-side__row:last-child { border-bottom: none; }
77
+
78
+ .ral-diff-side__attr { font-weight: 500; color: #333; min-width: 100px; flex-shrink: 0; }
79
+
80
+ .ral-diff-side__panel--before .ral-diff-side__value {
81
+ color: var(--ral-danger);
82
+ font-family: monospace;
83
+ background: #fff5f5;
84
+ padding: 1px 4px;
85
+ border-radius: 3px;
86
+ }
87
+
88
+ .ral-diff-side__panel--after .ral-diff-side__value {
89
+ color: var(--ral-success);
90
+ font-family: monospace;
91
+ background: #f0fdf4;
92
+ padding: 1px 4px;
93
+ border-radius: 3px;
94
+ }
@@ -0,0 +1,21 @@
1
+ .ral-timeline { display: flex; flex-direction: column; gap: 16px; }
2
+
3
+ .ral-timeline__entry {
4
+ background: var(--ral-surface);
5
+ border: 1px solid var(--ral-border);
6
+ border-radius: var(--ral-radius);
7
+ overflow: hidden;
8
+ box-shadow: var(--ral-shadow);
9
+ }
10
+
11
+ .ral-timeline__header {
12
+ display: flex;
13
+ align-items: center;
14
+ gap: 12px;
15
+ padding: 10px 16px;
16
+ background: var(--ral-bg);
17
+ border-bottom: 1px solid var(--ral-border);
18
+ font-size: 13px;
19
+ }
20
+
21
+ .ral-timeline__detail-link { margin-left: auto; }
@@ -0,0 +1,15 @@
1
+ .ral-card {
2
+ background: var(--ral-surface);
3
+ border: 1px solid var(--ral-border);
4
+ border-radius: var(--ral-radius);
5
+ padding: 16px;
6
+ margin-bottom: 20px;
7
+ box-shadow: var(--ral-shadow);
8
+ }
9
+
10
+ .ral-meta { display: grid; gap: 8px; }
11
+ .ral-meta__row { display: flex; gap: 16px; font-size: 13px; }
12
+ .ral-meta__row dt { width: 80px; color: var(--ral-muted); flex-shrink: 0; }
13
+ .ral-meta__row dd { color: var(--ral-text); }
14
+
15
+ .ral-entry-nav { display: flex; gap: 8px; align-items: center; }
@@ -0,0 +1,56 @@
1
+ nav.pagy.series-nav {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 4px;
5
+ margin-top: 16px;
6
+ font-size: 13px;
7
+ }
8
+
9
+ nav.pagy.series-nav a {
10
+ display: inline-flex;
11
+ align-items: center;
12
+ justify-content: center;
13
+ min-width: 32px;
14
+ height: 32px;
15
+ padding: 0 8px;
16
+ border: 1px solid var(--ral-border);
17
+ border-radius: 4px;
18
+ background: var(--ral-surface);
19
+ color: var(--ral-primary);
20
+ text-decoration: none;
21
+ }
22
+
23
+ nav.pagy.series-nav a:hover:not([aria-disabled="true"]) { background: var(--ral-bg); }
24
+
25
+ nav.pagy.series-nav a[aria-current="page"] {
26
+ background: var(--ral-primary);
27
+ color: #fff;
28
+ border-color: var(--ral-primary);
29
+ font-weight: 600;
30
+ }
31
+
32
+ nav.pagy.series-nav a[aria-disabled="true"] {
33
+ color: #aaa;
34
+ cursor: default;
35
+ border-color: var(--ral-border-subtle);
36
+ }
37
+
38
+ nav.pagy.series-nav a[role="separator"] {
39
+ border: none;
40
+ color: var(--ral-muted);
41
+ min-width: auto;
42
+ }
43
+
44
+ .ral-pagination__link {
45
+ display: inline-flex;
46
+ align-items: center;
47
+ height: 32px;
48
+ padding: 0 10px;
49
+ border: 1px solid var(--ral-border);
50
+ border-radius: 4px;
51
+ background: var(--ral-surface);
52
+ color: var(--ral-primary);
53
+ text-decoration: none;
54
+ font-size: 13px;
55
+ }
56
+ .ral-pagination__link:hover { background: var(--ral-bg); text-decoration: none; }
@@ -0,0 +1,54 @@
1
+ .ral-filters {
2
+ display: flex;
3
+ align-items: center;
4
+ gap: 8px;
5
+ flex-wrap: wrap;
6
+ margin-bottom: 16px;
7
+ }
8
+
9
+ .ral-filter-input {
10
+ padding: 5px 10px;
11
+ font-size: 13px;
12
+ border: 1px solid var(--ral-border);
13
+ border-radius: var(--ral-radius);
14
+ background: var(--ral-surface);
15
+ color: var(--ral-text);
16
+ min-width: 180px;
17
+ }
18
+ .ral-filter-input:focus { outline: 2px solid var(--ral-primary); outline-offset: -1px; }
19
+
20
+ .ral-filter-select {
21
+ padding: 5px 8px;
22
+ font-size: 13px;
23
+ border: 1px solid var(--ral-border);
24
+ border-radius: var(--ral-radius);
25
+ background: var(--ral-surface);
26
+ color: var(--ral-text);
27
+ cursor: pointer;
28
+ }
29
+
30
+ .ral-period-filter {
31
+ display: flex;
32
+ border: 1px solid var(--ral-border);
33
+ border-radius: var(--ral-radius);
34
+ overflow: hidden;
35
+ margin-left: auto;
36
+ }
37
+
38
+ .ral-period-btn {
39
+ padding: 5px 10px;
40
+ font-size: 13px;
41
+ font-weight: 500;
42
+ color: var(--ral-muted);
43
+ background: var(--ral-surface);
44
+ text-decoration: none;
45
+ }
46
+ .ral-period-btn + .ral-period-btn { border-left: 1px solid var(--ral-border); }
47
+ .ral-period-btn:hover:not(.ral-period-btn--active) {
48
+ background: var(--ral-bg);
49
+ color: var(--ral-text);
50
+ text-decoration: none;
51
+ }
52
+ .ral-period-btn--active { background: var(--ral-primary); color: #fff; }
53
+
54
+ .ral-filters__clear { font-size: 13px; }
@@ -1,15 +1 @@
1
- /*
2
- * This is a manifest file that'll be compiled into application.css, which will include all the files
3
- * listed below.
4
- *
5
- * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
- * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
- *
8
- * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
- * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
- * files in this directory. Styles in this file should be added after the last require_* statement.
11
- * It is generally better to create a new file per style scope.
12
- *
13
- *= require_tree .
14
- *= require_self
15
- */
1
+ /* Styles are loaded via the dashboard_stylesheets helper which globs _*.css files. */
@@ -1,4 +1,16 @@
1
1
  module RailsAuditLog
2
2
  class ApplicationController < ActionController::Base
3
+ include Pagy::Method
4
+
5
+ protect_from_forgery with: :exception
6
+ before_action :authenticate!
7
+
8
+ private
9
+
10
+ def authenticate!
11
+ return unless (auth = RailsAuditLog.authenticate)
12
+
13
+ instance_exec(self, &auth) || request_http_basic_authentication("Audit Log")
14
+ end
3
15
  end
4
16
  end
@@ -0,0 +1,31 @@
1
+ module RailsAuditLog
2
+ class AuditLogEntriesController < ApplicationController
3
+ def index
4
+ set_filters
5
+ @pagy, @entries = pagy(filtered_scope)
6
+ @item_types = AuditLogEntry.distinct.order(:item_type).pluck(:item_type)
7
+ end
8
+
9
+ def show
10
+ @entry = AuditLogEntry.find(params[:id])
11
+ end
12
+
13
+ private
14
+
15
+ def set_filters
16
+ @event = params[:event].presence_in(AuditLogEntry::EVENTS)
17
+ @item_type = params[:item_type].presence
18
+ @period = params[:period].presence_in(AuditLogEntry::PERIODS.keys)
19
+ @q = params[:q].presence
20
+ end
21
+
22
+ def filtered_scope
23
+ scope = AuditLogEntry.order(created_at: :desc)
24
+ scope = scope.where(event: @event) if @event
25
+ scope = scope.where(item_type: @item_type) if @item_type
26
+ scope = scope.for_period(@period) if @period
27
+ scope = scope.where("whodunnit_snapshot LIKE ?", "%#{@q}%") if @q
28
+ scope
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,13 @@
1
+ module RailsAuditLog
2
+ class ResourcesController < ApplicationController
3
+ def show
4
+ @item_type = params[:item_type]
5
+ @item_id = params[:item_id]
6
+ @pagy, @entries = pagy(
7
+ AuditLogEntry
8
+ .where(item_type: @item_type, item_id: @item_id)
9
+ .order(created_at: :asc)
10
+ )
11
+ end
12
+ end
13
+ end
@@ -1,4 +1,17 @@
1
1
  module RailsAuditLog
2
2
  module ApplicationHelper
3
+ def format_diff_value(value)
4
+ return "—" if value.nil?
5
+ return "#{value["type"]} ##{value["id"]}" if value.is_a?(Hash)
6
+
7
+ value.inspect
8
+ end
9
+
10
+ def dashboard_stylesheets
11
+ dir = RailsAuditLog::Engine.root.join("app/assets/stylesheets/rails_audit_log")
12
+ dir.glob("_*.css").sort.map do |file|
13
+ stylesheet_link_tag("rails_audit_log/#{file.basename}", media: "all")
14
+ end.join("\n").html_safe
15
+ end
3
16
  end
4
17
  end
@@ -0,0 +1,8 @@
1
+ import "@hotwired/turbo"
2
+ import { Application } from "@hotwired/stimulus"
3
+ import SearchController from "rails_audit_log/search_controller"
4
+ import DiffController from "rails_audit_log/diff_controller"
5
+
6
+ const application = Application.start()
7
+ application.register("search", SearchController)
8
+ application.register("diff", DiffController)
@@ -0,0 +1,20 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class DiffController extends Controller {
4
+ static targets = ["inline", "side", "inlineBtn", "sideBtn"]
5
+
6
+ connect() {
7
+ this.setMode(localStorage.getItem("ral-diff-mode") || "inline")
8
+ }
9
+
10
+ setInline() { this.setMode("inline") }
11
+ setSide() { this.setMode("side") }
12
+
13
+ setMode(mode) {
14
+ localStorage.setItem("ral-diff-mode", mode)
15
+ this.inlineTarget.hidden = mode !== "inline"
16
+ this.sideTarget.hidden = mode !== "side"
17
+ this.inlineBtnTarget.classList.toggle("ral-diff-btn--active", mode === "inline")
18
+ this.sideBtnTarget.classList.toggle("ral-diff-btn--active", mode === "side")
19
+ }
20
+ }
@@ -0,0 +1,12 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class SearchController extends Controller {
4
+ filter() {
5
+ clearTimeout(this._timeout)
6
+ this._timeout = setTimeout(() => this.element.requestSubmit(), 300)
7
+ }
8
+
9
+ select() {
10
+ this.element.requestSubmit()
11
+ }
12
+ }
@@ -2,8 +2,9 @@ module RailsAuditLog
2
2
  class AuditLogEntry < ApplicationRecord
3
3
  self.table_name = "audit_log_entries"
4
4
 
5
- EVENTS = %w[create update destroy].freeze
5
+ EVENTS = %w[create update destroy].freeze
6
6
  BLOB_COLUMNS = %w[object_changes object metadata].freeze
7
+ PERIODS = { "1h" => 1.hour, "24h" => 24.hours, "7d" => 7.days }.freeze
7
8
 
8
9
  def self.configure_connection!
9
10
  return unless (opts = RailsAuditLog.connects_to)
@@ -41,8 +42,9 @@ module RailsAuditLog
41
42
  }
42
43
 
43
44
  # Time scopes
44
- scope :since, ->(time) { where(created_at: time..) }
45
- scope :until, ->(time) { where(created_at: ..time) }
45
+ scope :since, ->(time) { where(created_at: time..) }
46
+ scope :until, ->(time) { where(created_at: ..time) }
47
+ scope :for_period, ->(period) { where(created_at: PERIODS[period].ago..) }
46
48
 
47
49
  # Projection scope — omits JSON blob columns for index/listing queries
48
50
  scope :slim, -> { select(column_names - BLOB_COLUMNS) }
@@ -1,17 +1,23 @@
1
1
  <!DOCTYPE html>
2
- <html>
2
+ <html lang="en">
3
3
  <head>
4
- <title>Rails audit log</title>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Audit Log</title>
7
+ <link rel="icon" href="data:,">
5
8
  <%= csrf_meta_tags %>
6
9
  <%= csp_meta_tag %>
7
-
8
- <%= yield :head %>
9
-
10
- <%= stylesheet_link_tag "rails_audit_log/application", media: "all" %>
10
+ <%= dashboard_stylesheets %>
11
+ <%= javascript_importmap_tags "rails_audit_log" %>
11
12
  </head>
12
13
  <body>
13
-
14
- <%= yield %>
15
-
14
+ <header class="ral-header">
15
+ <div class="ral-header__inner">
16
+ <%= link_to "Audit Log", root_path, class: "ral-header__logo" %>
17
+ </div>
18
+ </header>
19
+ <main class="ral-main">
20
+ <%= yield %>
21
+ </main>
16
22
  </body>
17
- </html>
23
+ </html>
@@ -0,0 +1,55 @@
1
+ <% changes = entry.object_changes.presence %>
2
+ <% if changes %>
3
+ <div class="ral-diff-wrap" data-controller="diff">
4
+ <div class="ral-diff-toggle">
5
+ <button class="ral-diff-btn ral-diff-btn--active"
6
+ data-diff-target="inlineBtn"
7
+ data-action="click->diff#setInline">Inline</button>
8
+ <button class="ral-diff-btn"
9
+ data-diff-target="sideBtn"
10
+ data-action="click->diff#setSide">Side by side</button>
11
+ </div>
12
+
13
+ <table class="ral-diff" data-diff-target="inline">
14
+ <thead>
15
+ <tr>
16
+ <th>Attribute</th>
17
+ <th>Before</th>
18
+ <th>After</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ <% changes.each do |attr, (before, after)| %>
23
+ <tr>
24
+ <td class="ral-diff__attr"><%= attr %></td>
25
+ <td class="ral-diff__before"><%= format_diff_value(before) %></td>
26
+ <td class="ral-diff__after"><%= format_diff_value(after) %></td>
27
+ </tr>
28
+ <% end %>
29
+ </tbody>
30
+ </table>
31
+
32
+ <div class="ral-diff-side" data-diff-target="side" hidden>
33
+ <div class="ral-diff-side__panel ral-diff-side__panel--before">
34
+ <div class="ral-diff-side__header">Before</div>
35
+ <% changes.each do |attr, (before, _after)| %>
36
+ <div class="ral-diff-side__row">
37
+ <span class="ral-diff-side__attr"><%= attr %></span>
38
+ <span class="ral-diff-side__value"><%= format_diff_value(before) %></span>
39
+ </div>
40
+ <% end %>
41
+ </div>
42
+ <div class="ral-diff-side__panel ral-diff-side__panel--after">
43
+ <div class="ral-diff-side__header">After</div>
44
+ <% changes.each do |attr, (_before, after)| %>
45
+ <div class="ral-diff-side__row">
46
+ <span class="ral-diff-side__attr"><%= attr %></span>
47
+ <span class="ral-diff-side__value"><%= format_diff_value(after) %></span>
48
+ </div>
49
+ <% end %>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ <% else %>
54
+ <p class="ral-muted">No changes recorded.</p>
55
+ <% end %>
@@ -0,0 +1,76 @@
1
+ <div class="ral-page-header">
2
+ <h1 class="ral-page-header__title">Audit Entries</h1>
3
+ <span class="ral-page-header__meta"><%= @pagy.count %> <%= "entry".pluralize(@pagy.count) %></span>
4
+ </div>
5
+
6
+ <%= turbo_frame_tag "ral-entries", data: { turbo_action: "advance" } do %>
7
+ <form class="ral-filters" action="<%= audit_log_entries_path %>" method="get"
8
+ data-controller="search">
9
+ <%= hidden_field_tag :period, @period if @period %>
10
+
11
+ <input type="search" name="q" value="<%= @q %>"
12
+ class="ral-filter-input" placeholder="Filter by actor…"
13
+ autocomplete="off" aria-label="Filter by actor"
14
+ data-action="input->search#filter">
15
+
16
+ <select name="event" class="ral-filter-select" aria-label="Filter by event"
17
+ data-action="change->search#select">
18
+ <option value="">All events</option>
19
+ <% RailsAuditLog::AuditLogEntry::EVENTS.each do |event| %>
20
+ <option value="<%= event %>" <%= "selected" if @event == event %>><%= event.capitalize %></option>
21
+ <% end %>
22
+ </select>
23
+
24
+ <% if @item_types.size > 1 %>
25
+ <select name="item_type" class="ral-filter-select" aria-label="Filter by resource"
26
+ data-action="change->search#select">
27
+ <option value="">All resources</option>
28
+ <% @item_types.each do |type| %>
29
+ <option value="<%= type %>" <%= "selected" if @item_type == type %>><%= type %></option>
30
+ <% end %>
31
+ </select>
32
+ <% end %>
33
+
34
+ <div class="ral-period-filter" role="group" aria-label="Time period">
35
+ <%= link_to "All", audit_log_entries_path(event: @event, item_type: @item_type, q: @q),
36
+ class: "ral-period-btn#{" ral-period-btn--active" if @period.blank?}" %>
37
+ <% RailsAuditLog::AuditLogEntry::PERIODS.each_key do |p| %>
38
+ <%= link_to p, audit_log_entries_path(event: @event, item_type: @item_type, q: @q, period: p),
39
+ class: "ral-period-btn#{" ral-period-btn--active" if @period == p}" %>
40
+ <% end %>
41
+ </div>
42
+
43
+ <% if @event || @item_type || @period || @q %>
44
+ <%= link_to "Clear", audit_log_entries_path, class: "ral-link ral-filters__clear" %>
45
+ <% end %>
46
+ </form>
47
+
48
+ <% if @entries.any? %>
49
+ <div class="ral-table-wrap">
50
+ <table class="ral-table">
51
+ <thead>
52
+ <tr>
53
+ <th>Event</th>
54
+ <th>Resource</th>
55
+ <th>Actor</th>
56
+ <th>When</th>
57
+ </tr>
58
+ </thead>
59
+ <tbody>
60
+ <% @entries.each do |entry| %>
61
+ <tr>
62
+ <td><span class="ral-badge ral-badge--<%= entry.event %>"><%= entry.event %></span></td>
63
+ <td><%= link_to "#{entry.item_type} ##{entry.item_id}", resource_path(item_type: entry.item_type, item_id: entry.item_id), class: "ral-link", data: { turbo_frame: "_top" } %></td>
64
+ <td><%= entry.whodunnit_snapshot.presence || (entry.actor_type ? "#{entry.actor_type} \##{entry.actor_id}" : "—") %></td>
65
+ <td class="ral-timestamp"><%= entry.created_at.utc.strftime("%Y-%m-%d %H:%M UTC") %></td>
66
+ </tr>
67
+ <% end %>
68
+ </tbody>
69
+ </table>
70
+ </div>
71
+
72
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
73
+ <% else %>
74
+ <p class="ral-empty">No audit entries found.</p>
75
+ <% end %>
76
+ <% end %>
@@ -0,0 +1,50 @@
1
+ <div class="ral-page-header">
2
+ <div>
3
+ <p class="ral-breadcrumb">
4
+ <%= link_to "Audit Entries", audit_log_entries_path, class: "ral-link" %> /
5
+ <%= link_to "#{@entry.item_type} ##{@entry.item_id}", resource_path(item_type: @entry.item_type, item_id: @entry.item_id), class: "ral-link" %>
6
+ </p>
7
+ <h1 class="ral-page-header__title">Entry #<%= @entry.id %></h1>
8
+ </div>
9
+ <nav class="ral-entry-nav" aria-label="Entry navigation">
10
+ <% if (prev_entry = @entry.previous) %>
11
+ <%= link_to "← Previous", audit_log_entry_path(prev_entry), class: "ral-pagination__link" %>
12
+ <% end %>
13
+ <% if (next_entry = @entry.next) %>
14
+ <%= link_to "Next →", audit_log_entry_path(next_entry), class: "ral-pagination__link" %>
15
+ <% end %>
16
+ </nav>
17
+ </div>
18
+
19
+ <div class="ral-card">
20
+ <dl class="ral-meta">
21
+ <div class="ral-meta__row">
22
+ <dt>Event</dt>
23
+ <dd><span class="ral-badge ral-badge--<%= @entry.event %>"><%= @entry.event %></span></dd>
24
+ </div>
25
+ <div class="ral-meta__row">
26
+ <dt>Resource</dt>
27
+ <dd><%= link_to "#{@entry.item_type} ##{@entry.item_id}", resource_path(item_type: @entry.item_type, item_id: @entry.item_id), class: "ral-link" %></dd>
28
+ </div>
29
+ <% actor = @entry.whodunnit_snapshot.presence || (@entry.actor_type ? "#{@entry.actor_type} \##{@entry.actor_id}" : "—") %>
30
+ <div class="ral-meta__row">
31
+ <dt>Actor</dt>
32
+ <dd><%= actor %></dd>
33
+ </div>
34
+ <div class="ral-meta__row">
35
+ <dt>When</dt>
36
+ <dd class="ral-timestamp"><%= @entry.created_at.utc.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
37
+ </div>
38
+ <% if @entry.reason.present? %>
39
+ <div class="ral-meta__row">
40
+ <dt>Reason</dt>
41
+ <dd><%= @entry.reason %></dd>
42
+ </div>
43
+ <% end %>
44
+ </dl>
45
+ </div>
46
+
47
+ <h2 class="ral-section-title">Changes</h2>
48
+ <div class="ral-card">
49
+ <%= render "diff", entry: @entry %>
50
+ </div>
@@ -0,0 +1,35 @@
1
+ <div class="ral-page-header">
2
+ <div>
3
+ <p class="ral-breadcrumb"><%= link_to "Audit Entries", audit_log_entries_path, class: "ral-link" %></p>
4
+ <h1 class="ral-page-header__title"><%= @item_type %> #<%= @item_id %></h1>
5
+ </div>
6
+ <span class="ral-page-header__meta"><%= @pagy.count %> <%= "entry".pluralize(@pagy.count) %></span>
7
+ </div>
8
+
9
+ <%= turbo_frame_tag "ral-resource-entries", data: { turbo_action: "advance" } do %>
10
+ <% if @entries.any? %>
11
+ <div class="ral-timeline">
12
+ <% @entries.each do |entry| %>
13
+ <div class="ral-timeline__entry">
14
+ <div class="ral-timeline__header">
15
+ <span class="ral-badge ral-badge--<%= entry.event %>"><%= entry.event %></span>
16
+ <span class="ral-timestamp"><%= entry.created_at.utc.strftime("%Y-%m-%d %H:%M UTC") %></span>
17
+ <% actor = entry.whodunnit_snapshot.presence || (entry.actor_type ? "#{entry.actor_type} \##{entry.actor_id}" : nil) %>
18
+ <% if actor %>
19
+ <span class="ral-muted">by <%= actor %></span>
20
+ <% end %>
21
+ <% if entry.reason.present? %>
22
+ <span class="ral-reason">"<%= entry.reason %>"</span>
23
+ <% end %>
24
+ <%= link_to "Details →", audit_log_entry_path(entry), class: "ral-link ral-timeline__detail-link", data: { turbo_frame: "_top" } %>
25
+ </div>
26
+ <%= render "rails_audit_log/audit_log_entries/diff", entry: entry %>
27
+ </div>
28
+ <% end %>
29
+ </div>
30
+
31
+ <%== @pagy.series_nav(aria_label: "Pagination") if @pagy.pages > 1 %>
32
+ <% else %>
33
+ <p class="ral-empty">No audit entries for this record.</p>
34
+ <% end %>
35
+ <% end %>
@@ -0,0 +1,5 @@
1
+ pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"
2
+ pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
3
+ pin "rails_audit_log", to: "rails_audit_log/application.js"
4
+ pin "rails_audit_log/search_controller", to: "rails_audit_log/search_controller.js"
5
+ pin "rails_audit_log/diff_controller", to: "rails_audit_log/diff_controller.js"
data/config/routes.rb CHANGED
@@ -1,2 +1,5 @@
1
1
  RailsAuditLog::Engine.routes.draw do
2
+ root to: "audit_log_entries#index"
3
+ resources :audit_log_entries, only: [:index, :show]
4
+ get "resources/:item_type/:item_id", to: "resources#show", as: :resource
2
5
  end
@@ -28,4 +28,14 @@ RailsAuditLog.configure do |config|
28
28
 
29
29
  # Route AuditLogEntry to a dedicated database (Rails multi-DB). Default: nil
30
30
  # config.connects_to = { database: { writing: :audit_log, reading: :audit_log } }
31
+
32
+ # Number of entries per page in the web dashboard. Default: 25
33
+ # config.page_size = 50
34
+
35
+ # Gate web dashboard access. Block runs in controller context — controller
36
+ # methods like current_user are available directly, or accept the controller
37
+ # as an argument. Falls back to HTTP Basic auth if the block returns falsy.
38
+ # Leave unset to allow unauthenticated access (development default).
39
+ # config.authenticate { current_user&.admin? }
40
+ # config.authenticate { |c| c.current_user&.admin? }
31
41
  end
@@ -1,7 +1,35 @@
1
+ require "turbo-rails"
2
+ require "importmap-rails"
3
+ require "pagy"
4
+ require "pagy/toolbox/paginators/method"
5
+
1
6
  module RailsAuditLog
2
7
  class Engine < ::Rails::Engine
3
8
  isolate_namespace RailsAuditLog
4
9
 
10
+ config.i18n.load_path += Gem.find_files("pagy/locales/en.yml")
11
+
12
+ initializer "rails_audit_log.pagy" do |app|
13
+ app.config.after_initialize do
14
+ Pagy::OPTIONS[:limit] = RailsAuditLog.page_size
15
+ end
16
+ end
17
+
18
+ initializer "rails_audit_log.assets" do |app|
19
+ if app.config.respond_to?(:assets)
20
+ app.config.assets.paths << root.join("app/assets/stylesheets")
21
+ app.config.assets.paths << root.join("app/assets/images")
22
+ app.config.assets.paths << root.join("app/javascript")
23
+ end
24
+ end
25
+
26
+ initializer "rails_audit_log.importmap", before: "importmap" do |app|
27
+ if app.config.respond_to?(:importmap)
28
+ app.config.importmap.paths << root.join("config/importmap.rb")
29
+ app.config.importmap.cache_sweepers << root.join("app/javascript")
30
+ end
31
+ end
32
+
5
33
  initializer "rails_audit_log.connect_audit_db" do
6
34
  ActiveSupport.on_load(:active_record) do
7
35
  RailsAuditLog::AuditLogEntry.configure_connection!
@@ -1,3 +1,3 @@
1
1
  module RailsAuditLog
2
- VERSION = "0.8.0"
2
+ VERSION = "0.9.0"
3
3
  end
@@ -14,6 +14,12 @@ module RailsAuditLog
14
14
  mattr_accessor :version_limit, default: nil
15
15
  mattr_accessor :async, default: false
16
16
  mattr_accessor :connects_to, default: nil
17
+ mattr_accessor :page_size, default: 25
18
+
19
+ def self.authenticate(&block)
20
+ @authenticate = block if block_given?
21
+ @authenticate
22
+ end
17
23
  mattr_accessor :whodunnit_display, default: ->(actor) {
18
24
  actor.respond_to?(:name) ? actor.name.to_s : actor.to_s
19
25
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_audit_log
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -23,6 +23,48 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '7.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: turbo-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: importmap-rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '1.2'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '1.2'
54
+ - !ruby/object:Gem::Dependency
55
+ name: pagy
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '43.0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '43.0'
26
68
  description: A modern Rails engine that tracks ActiveRecord create, update, and destroy
27
69
  events with JSON-first storage, whodunnit actor context, and a clean query API.
28
70
  Drop-in replacement for PaperTrail with no legacy baggage.
@@ -35,16 +77,35 @@ files:
35
77
  - MIT-LICENSE
36
78
  - README.md
37
79
  - Rakefile
80
+ - app/assets/stylesheets/rails_audit_log/_01_base.css
81
+ - app/assets/stylesheets/rails_audit_log/_02_layout.css
82
+ - app/assets/stylesheets/rails_audit_log/_03_table.css
83
+ - app/assets/stylesheets/rails_audit_log/_04_badges.css
84
+ - app/assets/stylesheets/rails_audit_log/_05_diff.css
85
+ - app/assets/stylesheets/rails_audit_log/_06_timeline.css
86
+ - app/assets/stylesheets/rails_audit_log/_07_detail.css
87
+ - app/assets/stylesheets/rails_audit_log/_08_pagination.css
88
+ - app/assets/stylesheets/rails_audit_log/_09_filters.css
38
89
  - app/assets/stylesheets/rails_audit_log/application.css
39
90
  - app/concerns/rails_audit_log/auditable.rb
40
91
  - app/concerns/rails_audit_log/controller.rb
41
92
  - app/controllers/rails_audit_log/application_controller.rb
93
+ - app/controllers/rails_audit_log/audit_log_entries_controller.rb
94
+ - app/controllers/rails_audit_log/resources_controller.rb
42
95
  - app/helpers/rails_audit_log/application_helper.rb
96
+ - app/javascript/rails_audit_log/application.js
97
+ - app/javascript/rails_audit_log/diff_controller.js
98
+ - app/javascript/rails_audit_log/search_controller.js
43
99
  - app/jobs/rails_audit_log/application_job.rb
44
100
  - app/jobs/rails_audit_log/write_audit_log_job.rb
45
101
  - app/models/rails_audit_log/application_record.rb
46
102
  - app/models/rails_audit_log/audit_log_entry.rb
47
103
  - app/views/layouts/rails_audit_log/application.html.erb
104
+ - app/views/rails_audit_log/audit_log_entries/_diff.html.erb
105
+ - app/views/rails_audit_log/audit_log_entries/index.html.erb
106
+ - app/views/rails_audit_log/audit_log_entries/show.html.erb
107
+ - app/views/rails_audit_log/resources/show.html.erb
108
+ - config/importmap.rb
48
109
  - config/routes.rb
49
110
  - lib/generators/rails_audit_log/initializer/initializer_generator.rb
50
111
  - lib/generators/rails_audit_log/initializer/templates/rails_audit_log.rb