cache_bro 0.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 (34) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +114 -0
  4. data/Rakefile +3 -0
  5. data/app/controllers/cache_bro/application_controller.rb +91 -0
  6. data/app/controllers/cache_bro/audit_events_controller.rb +38 -0
  7. data/app/controllers/cache_bro/flushes_controller.rb +32 -0
  8. data/app/controllers/cache_bro/keys_controller.rb +39 -0
  9. data/app/controllers/cache_bro/purges_controller.rb +38 -0
  10. data/app/helpers/cache_bro/application_helper.rb +63 -0
  11. data/app/models/cache_bro/application_record.rb +6 -0
  12. data/app/models/cache_bro/audit_event.rb +25 -0
  13. data/app/views/cache_bro/audit_events/index.html.erb +39 -0
  14. data/app/views/cache_bro/audit_events/show.html.erb +48 -0
  15. data/app/views/cache_bro/flushes/new.html.erb +19 -0
  16. data/app/views/cache_bro/keys/index.html.erb +36 -0
  17. data/app/views/cache_bro/keys/show.html.erb +57 -0
  18. data/app/views/cache_bro/purges/new.html.erb +17 -0
  19. data/app/views/cache_bro/purges/preview.html.erb +37 -0
  20. data/app/views/cache_bro/shared/_store_tabs.html.erb +9 -0
  21. data/app/views/cache_bro/shared/forbidden.html.erb +3 -0
  22. data/app/views/cache_bro/shared/unsupported.html.erb +3 -0
  23. data/config/routes.rb +18 -0
  24. data/db/migrate/20261115130000_create_cache_bro_audit_events.rb +25 -0
  25. data/lib/cache_bro/audit.rb +25 -0
  26. data/lib/cache_bro/configuration.rb +113 -0
  27. data/lib/cache_bro/engine.rb +25 -0
  28. data/lib/cache_bro/errors.rb +29 -0
  29. data/lib/cache_bro/redis_store.rb +220 -0
  30. data/lib/cache_bro/version.rb +3 -0
  31. data/lib/cache_bro.rb +43 -0
  32. data/lib/generators/cache_bro/install/install_generator.rb +35 -0
  33. data/lib/generators/cache_bro/install/templates/cache_bro.rb +32 -0
  34. metadata +109 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6287878eefcb94a718dfd1a6d1540355185e989bdc5bc21686933c5a09331f45
4
+ data.tar.gz: d9ab530ca70ac53107bd557da234b55337824a6523a2596e51f7fd4fc7512e9d
5
+ SHA512:
6
+ metadata.gz: fac35f3a49ba16a2b2e87afe7c222356ee3904ff5dad45a89a5352913d96644ce23e486ff37c1d15f67f53d31fa4a68f349066ce9f8a2ffe1dc13d5c828b1e8f
7
+ data.tar.gz: 60fd4404a3f9e3e1d0494f14550cbd3961e0b71fd56d291fcc013be8873b936e72cc8248f4dcc39dea7b88405714e5018740cf3223ea532d1cc454c7f99da62c
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Ruby Dev SRL (https://www.rubydev.ro)
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # CacheBro
2
+
3
+ Browse, inspect and purge your Rails **Redis** cache from the browser, with every action audited.
4
+
5
+ CacheBro is a section of the [BroHub](https://github.com/rubydevro/bro_hub) dashboard, next to EnvBro and SafeBro.
6
+ It's for teams where developers already have a console: it makes the common cache chores easier to do at scale,
7
+ and makes the ones done through the UI impossible to do unrecorded.
8
+
9
+ ## What it does
10
+
11
+ | | |
12
+ |---|---|
13
+ | **Keys** | Search keys (plain text matches anywhere, `*` and `?` make a pattern). See expiry and size. |
14
+ | **Reveal** | Show what a key holds. Its own permission, always audited. |
15
+ | **Delete** | Delete one key. |
16
+ | **Purge** | Delete every key matching a pattern. You see how many match first, and a sample. |
17
+ | **Flush** | Delete everything under the store's namespace. You type the store's name to confirm. |
18
+ | **Audit log** | Who revealed, deleted or purged what, how many, and why. Append-only. |
19
+
20
+ ## Install
21
+
22
+ ```ruby
23
+ gem "cache_bro"
24
+ ```
25
+
26
+ ```
27
+ bin/rails generate cache_bro:install
28
+ bin/rails db:migrate
29
+ ```
30
+
31
+ The installer sets up BroHub if it isn't yet, adds `config/initializers/cache_bro.rb` and copies the migration.
32
+ CacheBro then lives at `<hub>/cache_bro`.
33
+
34
+ Give your cache store a **namespace**:
35
+
36
+ ```ruby
37
+ config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"], namespace: "myapp" }
38
+ ```
39
+
40
+ Everything CacheBro shows or deletes is under that namespace. Without one the cache shares its Redis database
41
+ with whatever else lives there: you'll see a warning, and Flush is disabled.
42
+
43
+ ## Permissions
44
+
45
+ Plain lambdas, like the other Bro gems. Everything is denied until you say otherwise.
46
+
47
+ ```ruby
48
+ CacheBro.configure do |config|
49
+ config.can_view_keys = ->(user) { user&.developer? } # search keys, see expiry and size
50
+ config.can_view_values = ->(user) { user&.senior? } # reveal values (audited)
51
+ config.can_delete = ->(user) { user&.developer? } # delete one key
52
+ config.can_purge = ->(user) { user&.lead? } # delete by pattern
53
+ config.can_flush = ->(user) { user&.admin? } # delete the whole namespace
54
+ config.can_audit = ->(user) { user&.admin? } # read the audit log
55
+ end
56
+ ```
57
+
58
+ Higher includes lower: `flush` includes `purge` and `delete`, `purge` includes `delete`, and all of them include
59
+ `view_keys`. `view_values` and `audit` stand alone on purpose: values can hold personal data, and the people who
60
+ clean the cache shouldn't automatically read it.
61
+
62
+ ## Audit
63
+
64
+ Every reveal, delete, purge and flush is recorded in `cache_bro_audit_events`: who, what (the key or the pattern,
65
+ never the value), how many keys, which store and environment, when, and the reason if one was given. Entries can't be
66
+ changed or deleted through the model.
67
+
68
+ - Purges and flushes also keep a sample of the keys deleted.
69
+ - A reveal is recorded **before** the value is shown. If the audit log can't be written, nothing is revealed.
70
+ - If a purge fails part way (Redis goes away), what was deleted up to then is still recorded, marked as incomplete.
71
+ - Require a typed reason with `config.reason_required_for = %i[ purge flush ]` (default). Add `:reveal` and `:delete`
72
+ if you want one for those too.
73
+
74
+ Keys can contain personal data too (`user/ann@example.com/...`). Anyone with `view_keys` sees them.
75
+
76
+ ## Safe by construction
77
+
78
+ - Redis is only walked with `SCAN`, in small steps. Never `KEYS`. A search page that doesn't cover the whole keyspace
79
+ gives you a "keep looking" link.
80
+ - Every key is put back under the namespace before it touches Redis, so nothing outside it can be named.
81
+ - Purge patterns are used as typed (`posts/*` is a prefix), refuse to match everything, and stop at
82
+ `config.max_purge_keys` (100,000) per run. Run it again to continue.
83
+ - Values are shown as escaped text, truncated to `config.value_preview_length`. Reveals are `POST`s, so a link
84
+ prefetcher can't "view" a value.
85
+ - Pages are `Cache-Control: no-store`.
86
+
87
+ ## Several stores
88
+
89
+ ```ruby
90
+ config.stores = {
91
+ "App cache" => -> { Rails.cache },
92
+ "Rack::Attack" => -> { Rack::Attack.cache.store }
93
+ }
94
+ ```
95
+
96
+ Only `:redis_cache_store` on a single Redis server is supported for now. Other stores are listed with an
97
+ explanation instead of failing.
98
+
99
+ ## Not a sandbox
100
+
101
+ CacheBro is a convenience and an audit trail for people who already have console access. Someone with a console can
102
+ still do all of this there, unrecorded. Pair it with [SafeBro](https://github.com/rubydevro/safe_bro) if you want the
103
+ console recorded too.
104
+
105
+ ## Development
106
+
107
+ ```
108
+ bin/rails test # needs Redis on localhost (REDIS_URL, db 15 by default; only the test namespace is touched)
109
+ bin/rubocop
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,91 @@
1
+ module CacheBro
2
+ class ApplicationController < CacheBro.config.parent_controller.constantize
3
+ include BroHub::ControllerBase
4
+ bro_hub_section :cache_bro
5
+
6
+ before_action :cache_bro_require_ssl
7
+ before_action :cache_bro_no_store
8
+ before_action :cache_bro_authenticate!
9
+
10
+ helper_method :cache_bro_user, :cache_bro_user_label, :cache_bro_store, :cache_bro_allows?
11
+
12
+ rescue_from CacheBro::NotAllowed, with: :cache_bro_forbidden
13
+ rescue_from CacheBro::UnknownStore, with: :cache_bro_unknown_store
14
+ rescue_from CacheBro::UnsupportedStore, with: :cache_bro_unsupported
15
+ rescue_from CacheBro::ReasonRequired, CacheBro::InvalidPattern, with: :cache_bro_rejected
16
+
17
+ private
18
+ # The shell shows who is signed in, by CacheBro's rules.
19
+ def bro_hub_user
20
+ cache_bro_user
21
+ end
22
+
23
+ def cache_bro_user
24
+ return @cache_bro_user if defined?(@cache_bro_user)
25
+
26
+ @cache_bro_user = CacheBro.config.current_user.call(self)
27
+ end
28
+
29
+ def cache_bro_user_label
30
+ CacheBro.config.user_label.call(cache_bro_user) if cache_bro_user
31
+ end
32
+
33
+ def cache_bro_allows?(permission)
34
+ CacheBro.config.allows?(cache_bro_user, permission)
35
+ end
36
+
37
+ def authorize!(permission)
38
+ raise CacheBro::NotAllowed, "Not allowed" unless cache_bro_allows?(permission)
39
+ end
40
+
41
+ # Anyone who may use some part of CacheBro gets in; each action then checks its own permission.
42
+ def cache_bro_authenticate!
43
+ raise CacheBro::NotAllowed, "Not allowed" unless cache_bro_allows?(:view_keys) || cache_bro_allows?(:audit)
44
+ end
45
+
46
+ # The store the page is about: ?store=Name, or the first configured one.
47
+ def cache_bro_store
48
+ @cache_bro_store ||= CacheBro.store(params[:store])
49
+ end
50
+
51
+ # The typed reason, or nil. Raises when the action needs one and there is none.
52
+ def reason_for(action)
53
+ reason = params[:reason].to_s.strip.presence
54
+ raise CacheBro::ReasonRequired, "Say why. #{action.to_s.capitalize} needs a reason." if reason.nil? && CacheBro.config.reason_required?(action)
55
+
56
+ reason
57
+ end
58
+
59
+ def cache_bro_require_ssl
60
+ head :forbidden if CacheBro.config.require_ssl && !request.ssl?
61
+ end
62
+
63
+ # Keys and (revealed) values are production data: keep them out of caches.
64
+ def cache_bro_no_store
65
+ response.headers["Cache-Control"] = "no-store"
66
+ response.headers["Pragma"] = "no-cache"
67
+ end
68
+
69
+ def cache_bro_forbidden
70
+ render "cache_bro/shared/forbidden", status: :forbidden
71
+ end
72
+
73
+ def cache_bro_unknown_store
74
+ head :not_found
75
+ end
76
+
77
+ def cache_bro_unsupported(error)
78
+ @unsupported = error.message
79
+ render "cache_bro/shared/unsupported", status: :unprocessable_entity
80
+ end
81
+
82
+ def cache_bro_rejected(error)
83
+ redirect_back fallback_location: root_path(store: params[:store]), alert: error.message
84
+ end
85
+
86
+ # A SCAN cursor is a number. Anything else starts over.
87
+ def cursor_param
88
+ params[:cursor].to_s.match?(/\A\d+\z/) ? params[:cursor].to_s : "0"
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,38 @@
1
+ module CacheBro
2
+ class AuditEventsController < ApplicationController
3
+ PER_PAGE = CacheBro::ApplicationHelper::PER_PAGE
4
+
5
+ before_action { authorize!(:audit) }
6
+ helper_method :filter_params
7
+
8
+ def index
9
+ @filter = filter_params
10
+ @events = filtered.recent.limit(PER_PAGE).offset(page * PER_PAGE)
11
+ @total = filtered.count
12
+ @page = page
13
+ @counts = filtered.group(:event).count
14
+ end
15
+
16
+ def show
17
+ @event = AuditEvent.find(params[:id])
18
+ end
19
+
20
+ private
21
+ def filtered
22
+ scope = AuditEvent.all
23
+ scope = scope.where(event: filter_params[:event]) if AuditEvent::EVENTS.include?(filter_params[:event])
24
+ scope = scope.where(user_label: filter_params[:user]) if filter_params[:user].present?
25
+ scope = scope.where(store: filter_params[:store]) if filter_params[:store].present?
26
+ scope = scope.where("target LIKE ?", "%#{AuditEvent.sanitize_sql_like(filter_params[:q])}%") if filter_params[:q].present?
27
+ scope
28
+ end
29
+
30
+ def filter_params
31
+ @filter_params ||= params.permit(:event, :user, :store, :q).to_h.symbolize_keys.compact_blank
32
+ end
33
+
34
+ def page
35
+ [ params[:page].to_i, 0 ].max
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ module CacheBro
2
+ class FlushesController < ApplicationController
3
+ before_action { authorize!(:flush) }
4
+
5
+ def new
6
+ end
7
+
8
+ # Typing the store's name is the confirmation.
9
+ def create
10
+ reason = reason_for(:flush)
11
+ unless params[:confirm].to_s == cache_bro_store.name
12
+ return redirect_to flush_path(store: cache_bro_store.name), alert: "Type the store name to confirm. Nothing was deleted."
13
+ end
14
+
15
+ result = begin
16
+ cache_bro_store.flush
17
+ rescue CacheBro::SweepFailed => error
18
+ record(error.matches, reason, completed: false)
19
+ raise
20
+ end
21
+ record(result, reason, completed: true)
22
+
23
+ redirect_to flush_path(store: cache_bro_store.name), notice: "Flushed #{cache_bro_store.name}: #{helpers.cache_bro_count(result.count, "key")} deleted."
24
+ end
25
+
26
+ private
27
+ def record(result, reason, completed:)
28
+ Audit.record(:store_flushed, user: cache_bro_user, store: cache_bro_store.name, count: result.count, reason: reason,
29
+ namespace: cache_bro_store.namespace, sample: result.sample, completed: completed)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,39 @@
1
+ module CacheBro
2
+ class KeysController < ApplicationController
3
+ before_action { authorize!(:view_keys) }
4
+ before_action :load_key, only: %i[ show reveal destroy ]
5
+
6
+ def index
7
+ @query = params[:q].to_s.strip.presence
8
+ @listing = cache_bro_store.list(query: @query, cursor: cursor_param)
9
+ end
10
+
11
+ def show
12
+ end
13
+
14
+ # A POST, so a link prefetcher can't "view" a value. The audit entry is written first: if it can't be
15
+ # recorded, nothing is shown.
16
+ def reveal
17
+ authorize!(:view_values)
18
+ reason = reason_for(:reveal)
19
+ Audit.record(:value_viewed, user: cache_bro_user, store: cache_bro_store.name, target: @key, reason: reason)
20
+ @value = cache_bro_store.read(@key)
21
+ render :show
22
+ end
23
+
24
+ def destroy
25
+ authorize!(:delete)
26
+ reason = reason_for(:delete)
27
+ deleted = cache_bro_store.delete(@key)
28
+ Audit.record(:key_deleted, user: cache_bro_user, store: cache_bro_store.name, target: @key, count: deleted, reason: reason)
29
+ redirect_to root_path(store: cache_bro_store.name), notice: "Deleted #{@key}."
30
+ end
31
+
32
+ private
33
+ def load_key
34
+ @key = params[:key].to_s
35
+ @entry = cache_bro_store.stat(@key) unless @key.empty?
36
+ redirect_to root_path(store: cache_bro_store.name), alert: "That key no longer exists." unless @entry
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,38 @@
1
+ module CacheBro
2
+ class PurgesController < ApplicationController
3
+ before_action { authorize!(:purge) }
4
+
5
+ def new
6
+ end
7
+
8
+ # Counts what the pattern matches, without deleting, and asks to confirm.
9
+ def preview
10
+ @pattern = params[:pattern].to_s.strip
11
+ @reason = reason_for(:purge)
12
+ @matches = cache_bro_store.preview(@pattern)
13
+ end
14
+
15
+ def create
16
+ pattern = params[:pattern].to_s.strip
17
+ reason = reason_for(:purge)
18
+ result = begin
19
+ cache_bro_store.purge(pattern)
20
+ rescue CacheBro::SweepFailed => error
21
+ # Whatever got deleted before the failure is still recorded.
22
+ record(pattern, error.matches, reason, completed: false)
23
+ raise
24
+ end
25
+ record(pattern, result, reason, completed: true)
26
+
27
+ notice = "Deleted #{helpers.cache_bro_count(result.count, "key")} matching #{pattern}."
28
+ notice += " That is the limit for one purge, run it again to continue." if result.truncated
29
+ redirect_to purge_path(store: cache_bro_store.name), notice: notice
30
+ end
31
+
32
+ private
33
+ def record(pattern, result, reason, completed:)
34
+ Audit.record(:keys_purged, user: cache_bro_user, store: cache_bro_store.name, target: pattern, count: result.count,
35
+ reason: reason, sample: result.sample, truncated: result.truncated, completed: completed)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,63 @@
1
+ module CacheBro
2
+ module ApplicationHelper
3
+ PER_PAGE = 50
4
+
5
+ EVENT_LABELS = {
6
+ "value_viewed" => "Revealed", "key_deleted" => "Deleted", "keys_purged" => "Purged", "store_flushed" => "Flushed"
7
+ }.freeze
8
+ EVENT_TONES = { "value_viewed" => "amber", "key_deleted" => "red", "keys_purged" => "red", "store_flushed" => "red" }.freeze
9
+
10
+ def cache_bro_time(time)
11
+ return "-" unless time
12
+
13
+ content_tag(:time, time.utc.strftime("%Y-%m-%d %H:%M"), datetime: time.iso8601, title: time.utc.to_s)
14
+ end
15
+
16
+ def cache_bro_ttl(seconds)
17
+ seconds ? "in #{distance_of_time_in_words(0, seconds.round, include_seconds: true)}" : "no expiry"
18
+ end
19
+
20
+ def cache_bro_size(bytes)
21
+ bytes ? number_to_human_size(bytes) : "-"
22
+ end
23
+
24
+ def cache_bro_count(count, singular, plural = singular.pluralize)
25
+ "#{number_with_delimiter(count)} #{count == 1 ? singular : plural}"
26
+ end
27
+
28
+ def cache_bro_event_label(event)
29
+ EVENT_LABELS.fetch(event.to_s, event.to_s.humanize)
30
+ end
31
+
32
+ def cache_bro_event_tag(event)
33
+ content_tag(:span, cache_bro_event_label(event), class: "bh-tag is-#{EVENT_TONES.fetch(event.to_s, "muted")}")
34
+ end
35
+
36
+ # A reason box. Required when the config says so for that action.
37
+ def cache_bro_reason_field(action, placeholder: "Why? (ticket, incident, ...)")
38
+ required = CacheBro.config.reason_required?(action)
39
+ label_tag(:reason, class: "bh-field") do
40
+ safe_join([ (required ? "Reason (required)" : "Reason (optional)"),
41
+ text_field_tag(:reason, nil, class: "bh-input", placeholder: placeholder, required: required, autocomplete: "off") ])
42
+ end
43
+ end
44
+
45
+ # "Next →" for SCAN cursors. There is no "back": a cursor only goes forward.
46
+ def cache_bro_pager(listing, query:)
47
+ return unless listing.more?
48
+
49
+ content_tag(:div, class: "bh-meta", style: "margin-top:14px") do
50
+ link_to("Keep looking →", root_path(store: cache_bro_store.name, q: query, cursor: listing.cursor))
51
+ end
52
+ end
53
+
54
+ # "← Newer / Older →" for audit pages. `page` counts from 0.
55
+ def cache_bro_audit_pager(total:, page:)
56
+ newer = link_to("← Newer", url_for(request.query_parameters.merge(page: page - 1))) if page > 0
57
+ older = link_to("Older →", url_for(request.query_parameters.merge(page: page + 1))) if (page + 1) * PER_PAGE < total
58
+ content_tag(:div, class: "bh-meta", style: "display:flex;justify-content:space-between;gap:16px;margin-top:14px") do
59
+ safe_join([ content_tag(:span, cache_bro_count(total, "event")), content_tag(:span, safe_join([ newer, older ].compact, " ")) ])
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,6 @@
1
+ module CacheBro
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ self.table_name_prefix = "cache_bro_"
5
+ end
6
+ end
@@ -0,0 +1,25 @@
1
+ module CacheBro
2
+ # Who revealed, deleted or purged what, and when. Holds keys and patterns, never values.
3
+ class AuditEvent < ApplicationRecord
4
+ EVENTS = %w[ value_viewed key_deleted keys_purged store_flushed ].freeze
5
+
6
+ validates :event, inclusion: { in: EVENTS }
7
+ validates :environment, :store, presence: true
8
+
9
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
10
+
11
+ # Audit entries are append-only.
12
+ def readonly?
13
+ persisted?
14
+ end
15
+
16
+ # Keys the action touched, for a purge or flush (a sample, see config.audit_sample_size).
17
+ def sample
18
+ Array(details["sample"])
19
+ end
20
+
21
+ def truncated?
22
+ details["truncated"] == true
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,39 @@
1
+ <% bro_hub_crumb "Audit log" %>
2
+ <% content_for :bro_hub_crumb_actions do %>
3
+ <% [ [ "All", nil ], *CacheBro::AuditEvent::EVENTS.map { |event| [ cache_bro_event_label(event), event ] } ].each do |label, event| %>
4
+ <%= bro_hub_crumb_link label, audit_path(@filter.merge(event: event).compact), active: @filter[:event] == event %>
5
+ <% end %>
6
+ <% end %>
7
+
8
+ <%= bro_hub_hero eyebrow: "Audit log", title: @total, subtitle: @total == 1 ? "event" : "events",
9
+ lead: "Who revealed, deleted or purged what. Entries can't be changed. Keys and patterns are recorded, never values.",
10
+ stats: [ [ @counts.fetch("value_viewed", 0), "reveals", :amber ], [ %w[ key_deleted keys_purged store_flushed ].sum { |event| @counts.fetch(event, 0) }, "deletions", :red ] ] %>
11
+
12
+ <%= form_with url: audit_path, method: :get, class: "bh-filters" do %>
13
+ <%= hidden_field_tag :event, @filter[:event] if @filter[:event] %>
14
+ <%= text_field_tag :q, @filter[:q], placeholder: "Search keys and patterns", class: "bh-input is-strong", "aria-label": "Search keys and patterns" %>
15
+ <%= text_field_tag :user, @filter[:user], placeholder: "User", class: "bh-input", "aria-label": "User" %>
16
+ <% if CacheBro.config.store_names.size > 1 %>
17
+ <%= select_tag :store, options_for_select([ [ "Any store", "" ], *CacheBro.config.store_names ], @filter[:store]), class: "bh-select", "aria-label": "Store" %>
18
+ <% end %>
19
+ <%= submit_tag "Filter", name: nil, class: "bh-chip" %>
20
+ <%= link_to "Reset", audit_path(event: @filter[:event]), class: "bh-link-quiet" %>
21
+ <% end %>
22
+
23
+ <div class="bh-table-wrap">
24
+ <div class="bh-table" style="--cols: 150px 150px 100px 120px minmax(220px,1fr) 80px; --min: 900px">
25
+ <div class="bh-row is-head"><span>When</span><span>User</span><span>Action</span><span>Store</span><span>Key or pattern</span><span>Keys</span></div>
26
+ <% @events.each do |event| %>
27
+ <%= link_to audit_event_path(event), class: [ "bh-row", ("bh-edge-red" unless event.event == "value_viewed") ] do %>
28
+ <span class="bh-tone-violet"><%= cache_bro_time(event.created_at) %></span>
29
+ <span class="bh-cell-strong"><%= event.user_label %></span>
30
+ <span><%= cache_bro_event_tag(event.event) %></span>
31
+ <span class="bh-cell-muted"><%= event.store %></span>
32
+ <span class="bh-cell-text" style="overflow-wrap:anywhere"><%= truncate(event.target.to_s, length: 90) %></span>
33
+ <span><%= event.count %></span>
34
+ <% end %>
35
+ <% end %>
36
+ <% if @events.empty? %><div class="bh-empty">Nothing recorded.</div><% end %>
37
+ <%= cache_bro_audit_pager(total: @total, page: @page) %>
38
+ </div>
39
+ </div>
@@ -0,0 +1,48 @@
1
+ <% bro_hub_crumb "Audit log", "##{@event.id}" %>
2
+ <% content_for :bro_hub_crumb_actions do %>
3
+ <%= bro_hub_crumb_link "← Audit log", audit_path %>
4
+ <% end %>
5
+
6
+ <%= bro_hub_hero eyebrow: "#{@event.store} · #{@event.environment}", title: cache_bro_event_label(@event.event),
7
+ subtitle: ("#{cache_bro_count(@event.count, "key")}" if @event.count),
8
+ lead: safe_join([ content_tag(:strong, @event.user_label), " at #{@event.created_at.utc.strftime("%Y-%m-%d %H:%M UTC")}" ]) %>
9
+
10
+ <div class="bh-split">
11
+ <section class="bh-split-main">
12
+ <div class="bh-block-head"><h2 class="bh-h2"><%= @event.event == "keys_purged" ? "Pattern" : "Key" %></h2></div>
13
+ <% if @event.target.present? %>
14
+ <pre class="bh-cmd-code" style="white-space:pre-wrap;overflow-wrap:anywhere"><%= @event.target %></pre>
15
+ <% else %>
16
+ <p class="bh-hint">The whole namespace.</p>
17
+ <% end %>
18
+
19
+ <% if @event.sample.any? %>
20
+ <div class="bh-block-head" style="margin-top:24px">
21
+ <h2 class="bh-h2">Keys deleted</h2>
22
+ <span class="bh-meta">a sample of <%= @event.sample.size %><%= " (more were deleted)" if @event.count.to_i > @event.sample.size %></span>
23
+ </div>
24
+ <div class="bh-log">
25
+ <% @event.sample.each do |key| %>
26
+ <div class="bh-cmd"><pre class="bh-cmd-code" style="white-space:pre-wrap;overflow-wrap:anywhere"><%= key %></pre></div>
27
+ <% end %>
28
+ </div>
29
+ <% end %>
30
+ </section>
31
+
32
+ <div class="bh-split-aside">
33
+ <section class="bh-block">
34
+ <h2 class="bh-h2">Details</h2>
35
+ <dl class="bh-dl">
36
+ <dt>User</dt><dd><%= @event.user_label %></dd>
37
+ <dt>Action</dt><dd><%= cache_bro_event_tag(@event.event) %></dd>
38
+ <dt>When</dt><dd><%= cache_bro_time(@event.created_at) %></dd>
39
+ <dt>Store</dt><dd><%= @event.store %></dd>
40
+ <dt>Environment</dt><dd><%= @event.environment %></dd>
41
+ <dt>Keys</dt><dd><%= @event.count || "-" %></dd>
42
+ <dt>Reason</dt><dd><%= @event.reason.presence || "none given" %></dd>
43
+ <% if @event.details["completed"] == false %><dt>Outcome</dt><dd class="bh-tone-red">failed part way: only the keys counted here were deleted</dd><% end %>
44
+ <% if @event.truncated? %><dt>Limit</dt><dd class="bh-tone-amber">stopped at the per-purge limit</dd><% end %>
45
+ </dl>
46
+ </section>
47
+ </div>
48
+ </div>
@@ -0,0 +1,19 @@
1
+ <% bro_hub_crumb "Flush" %>
2
+ <%= render "cache_bro/shared/store_tabs", path: ->(name) { flush_path(store: name) } %>
3
+
4
+ <%= bro_hub_hero eyebrow: cache_bro_store.name, title: "Flush", tone: :red,
5
+ lead: "Delete everything in this cache store's namespace. Every cached page, fragment and value is rebuilt on demand, which can load your database. Recorded in the audit log." %>
6
+
7
+ <% if cache_bro_store.namespaced? %>
8
+ <%= form_with url: flush_path, method: :post, class: "bh-filters" do %>
9
+ <%= hidden_field_tag :store, cache_bro_store.name %>
10
+ <label class="bh-field">Type <strong><%= cache_bro_store.name %></strong> to confirm
11
+ <%= text_field_tag :confirm, nil, class: "bh-input", autocomplete: "off", required: true %>
12
+ </label>
13
+ <%= cache_bro_reason_field(:flush) %>
14
+ <%= submit_tag "Flush #{cache_bro_store.name}", name: nil, class: "bh-btn is-auto", style: "background:var(--bh-red)" %>
15
+ <% end %>
16
+ <p class="bh-hint" style="padding:0 var(--bh-pad)">Only keys under the namespace <code><%= cache_bro_store.namespace %></code> are deleted.</p>
17
+ <% else %>
18
+ <div class="bh-alert bh-alert-error">This store has no namespace, so flushing it would delete keys that aren't the cache's. Set <code>namespace:</code> on the cache store to enable this. You can still purge by pattern.</div>
19
+ <% end %>
@@ -0,0 +1,36 @@
1
+ <% bro_hub_crumb "Keys" %>
2
+ <%= render "cache_bro/shared/store_tabs", path: ->(name) { root_path(store: name) } %>
3
+
4
+ <%= bro_hub_hero eyebrow: [ cache_bro_store.name, (cache_bro_store.namespace ? "namespace #{cache_bro_store.namespace}" : "no namespace") ].join(" · "),
5
+ title: @listing.entries.size,
6
+ subtitle: @listing.more? ? "keys so far" : (@listing.entries.size == 1 ? "key" : "keys"),
7
+ lead: "Search what is in the cache. Plain text matches anywhere in the key; use * and ? for a pattern. " \
8
+ "Redis is walked in small steps, so on a big cache a page may show only part of the matches: keep looking to continue." %>
9
+
10
+ <% unless cache_bro_store.namespaced? %>
11
+ <div class="bh-alert bh-alert-error">This cache store has no namespace, so it shares its Redis database with whatever else lives there. Set <code>namespace:</code> on the cache store to keep CacheBro to the cache's own keys.</div>
12
+ <% end %>
13
+
14
+ <%= form_with url: root_path, method: :get, class: "bh-filters" do %>
15
+ <%= hidden_field_tag :store, cache_bro_store.name %>
16
+ <%= text_field_tag :q, @query, placeholder: "Search keys, e.g. posts/ or views/*/show*", class: "bh-input is-strong", "aria-label": "Search keys", autofocus: true %>
17
+ <%= submit_tag "Search", name: nil, class: "bh-chip" %>
18
+ <%= link_to "Reset", root_path(store: cache_bro_store.name), class: "bh-link-quiet" %>
19
+ <% end %>
20
+
21
+ <div class="bh-table-wrap">
22
+ <div class="bh-table" style="--cols: minmax(260px,1fr) 150px 100px; --min: 560px">
23
+ <div class="bh-row is-head"><span>Key</span><span>Expires</span><span>Size</span></div>
24
+ <% @listing.entries.each do |entry| %>
25
+ <%= link_to key_path(store: cache_bro_store.name, key: entry.key), class: "bh-row" do %>
26
+ <span class="bh-cell-strong" style="overflow-wrap:anywhere"><%= entry.key %></span>
27
+ <span class="bh-cell-muted"><%= cache_bro_ttl(entry.ttl) %></span>
28
+ <span class="bh-cell-muted"><%= cache_bro_size(entry.size) %></span>
29
+ <% end %>
30
+ <% end %>
31
+ <% if @listing.entries.empty? %>
32
+ <div class="bh-empty"><%= @listing.more? ? "Nothing here yet." : "No keys." %></div>
33
+ <% end %>
34
+ <%= cache_bro_pager(@listing, query: @query) %>
35
+ </div>
36
+ </div>