maquina-generators 0.5.0 → 0.7.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +125 -15
  3. data/lib/generators/maquina/app/app_generator.rb +9 -4
  4. data/lib/generators/maquina/app/templates/README.md.erb +6 -4
  5. data/lib/generators/maquina/app/templates/app/views/home/index.html.erb +18 -5
  6. data/lib/generators/maquina/app/templates/config/database.yml.tt +12 -0
  7. data/lib/generators/maquina/clave/clave_generator.rb +48 -0
  8. data/lib/generators/maquina/clave/templates/app/controllers/registration/verifications_controller.rb.tt +10 -1
  9. data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt +10 -1
  10. data/lib/generators/maquina/clave/templates/app/models/email_verification.rb.tt +18 -0
  11. data/lib/generators/maquina/clave/templates/config/locales/clave.en.yml +2 -0
  12. data/lib/generators/maquina/clave/templates/config/locales/clave.es.yml +2 -0
  13. data/lib/generators/maquina/csp_reports/USAGE +18 -0
  14. data/lib/generators/maquina/csp_reports/csp_reports_generator.rb +231 -0
  15. data/lib/generators/maquina/csp_reports/templates/app/controllers/csp_reports_controller.rb +24 -0
  16. data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation.rb +89 -0
  17. data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation_report.rb +30 -0
  18. data/lib/generators/maquina/csp_reports/templates/app/views/backstage/security/_csp_violations.html.erb +42 -0
  19. data/lib/generators/maquina/csp_reports/templates/db/create_csp_violations.rb.tt +23 -0
  20. data/lib/generators/maquina/mission_control_jobs/mission_control_jobs_generator.rb +32 -1
  21. data/lib/generators/maquina/mission_control_jobs/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  22. data/lib/generators/maquina/mission_control_jobs/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  23. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  24. data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/admin.html.erb +21 -0
  25. data/lib/generators/maquina/rack_attack/USAGE +8 -6
  26. data/lib/generators/maquina/rack_attack/rack_attack_generator.rb +6 -2
  27. data/lib/generators/maquina/rack_attack/templates/config/initializers/rack_attack.rb.tt +139 -57
  28. data/lib/generators/maquina/security/USAGE +15 -0
  29. data/lib/generators/maquina/security/security_generator.rb +278 -0
  30. data/lib/generators/maquina/security/templates/app/controllers/backstage/security_controller.rb.tt +45 -0
  31. data/{test/tmp/app/controllers/backstage_controller.rb → lib/generators/maquina/security/templates/app/controllers/backstage_controller.rb.tt} +0 -1
  32. data/lib/generators/maquina/security/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  33. data/lib/generators/maquina/security/templates/app/models/security/abuse_event.rb +36 -0
  34. data/lib/generators/maquina/security/templates/app/models/security/abuse_report.rb +112 -0
  35. data/lib/generators/maquina/security/templates/app/models/security/record.rb +6 -0
  36. data/lib/generators/maquina/security/templates/app/views/backstage/security/_addresses.html.erb +37 -0
  37. data/lib/generators/maquina/security/templates/app/views/backstage/security/_blocked.html.erb +53 -0
  38. data/lib/generators/maquina/security/templates/app/views/backstage/security/_by_day.html.erb +33 -0
  39. data/lib/generators/maquina/security/templates/app/views/backstage/security/_hosts.html.erb +31 -0
  40. data/lib/generators/maquina/security/templates/app/views/backstage/security/_paths.html.erb +33 -0
  41. data/lib/generators/maquina/security/templates/app/views/backstage/security/_recent.html.erb +37 -0
  42. data/lib/generators/maquina/security/templates/app/views/backstage/security/_utc_time.html.erb +2 -0
  43. data/lib/generators/maquina/security/templates/app/views/backstage/security/show.html.erb +47 -0
  44. data/lib/generators/maquina/security/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  45. data/lib/generators/maquina/security/templates/app/views/layouts/_admin_navigation.html.erb.tt +40 -0
  46. data/lib/generators/maquina/security/templates/app/views/layouts/admin.html.erb +21 -0
  47. data/{test/tmp/app/views/layouts/solid_errors/application.html.erb → lib/generators/maquina/security/templates/app/views/layouts/backstage/security.html.erb} +1 -1
  48. data/lib/generators/maquina/security/templates/config/initializers/rack_attack_events.rb +34 -0
  49. data/lib/generators/maquina/security/templates/db/security_schema.rb.tt +27 -0
  50. data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +32 -1
  51. data/lib/generators/maquina/solid_errors/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
  52. data/lib/generators/maquina/solid_errors/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
  53. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
  54. data/lib/generators/maquina/solid_errors/templates/app/views/layouts/admin.html.erb +21 -0
  55. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.html.erb +65 -0
  56. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.text.erb +30 -0
  57. data/lib/generators/maquina/solid_errors/templates/config/initializers/solid_errors.rb.tt +12 -0
  58. data/lib/maquina_generators/version.rb +1 -1
  59. data/test/generators/maquina/app_generator_test.rb +21 -0
  60. data/test/generators/maquina/clave_generator_test.rb +74 -0
  61. data/test/generators/maquina/csp_reports_generator_test.rb +151 -0
  62. data/test/generators/maquina/mission_control_jobs_generator_test.rb +28 -4
  63. data/test/generators/maquina/rack_attack_generator_test.rb +41 -19
  64. data/test/generators/maquina/security_generator_test.rb +380 -0
  65. data/test/generators/maquina/solid_errors_generator_test.rb +59 -4
  66. data/test/tmp/.gitignore +5 -0
  67. data/test/tmp/.rubocop.yml +11 -0
  68. data/test/tmp/.standard.yml +1 -0
  69. data/test/tmp/Gemfile +12 -2
  70. data/test/tmp/Procfile.dev +3 -0
  71. data/test/tmp/README.md +89 -0
  72. data/test/tmp/app/controllers/home_controller.rb +6 -0
  73. data/test/tmp/app/javascript/application.js +1 -0
  74. data/test/tmp/app/views/home/index.html.erb +204 -0
  75. data/test/tmp/app/views/layouts/application.html.erb +10 -0
  76. data/test/tmp/config/application.rb +15 -0
  77. data/test/tmp/config/database.yml +90 -0
  78. data/test/tmp/config/database.yml.example +90 -0
  79. data/test/tmp/config/environments/development.rb +8 -0
  80. data/test/tmp/config/environments/production.rb +6 -0
  81. data/test/tmp/config/initializers/generators.rb +4 -0
  82. data/test/tmp/config/routes.rb +1 -1
  83. metadata +60 -32
  84. data/test/tmp/app/agents/error_redactor.rb +0 -34
  85. data/test/tmp/app/agents/errors_query.rb +0 -84
  86. data/test/tmp/app/agents/failure_triage.rb +0 -21
  87. data/test/tmp/app/agents/get_exception_tool.rb +0 -26
  88. data/test/tmp/app/agents/list_failures_tool.rb +0 -20
  89. data/test/tmp/app/agents/top_exceptions_tool.rb +0 -18
  90. data/test/tmp/app/helpers/solid_errors_helper.rb +0 -9
  91. data/test/tmp/app/javascript/controllers/backtrace_filter_controller.js +0 -27
  92. data/test/tmp/app/javascript/controllers/clipboard_controller.js +0 -36
  93. data/test/tmp/app/views/layouts/_admin_navigation.html.erb +0 -28
  94. data/test/tmp/app/views/solid_errors/errors/_actions.html.erb +0 -22
  95. data/test/tmp/app/views/solid_errors/errors/_delete_button.html.erb +0 -10
  96. data/test/tmp/app/views/solid_errors/errors/_error_card.html.erb +0 -63
  97. data/test/tmp/app/views/solid_errors/errors/_resolve_button.html.erb +0 -11
  98. data/test/tmp/app/views/solid_errors/errors/index.html.erb +0 -37
  99. data/test/tmp/app/views/solid_errors/errors/show/_actions.html.erb +0 -33
  100. data/test/tmp/app/views/solid_errors/errors/show/_error_details.html.erb +0 -95
  101. data/test/tmp/app/views/solid_errors/errors/show/_header.html.erb +0 -18
  102. data/test/tmp/app/views/solid_errors/errors/show/_properties.html.erb +0 -65
  103. data/test/tmp/app/views/solid_errors/errors/show.html.erb +0 -23
  104. data/test/tmp/app/views/solid_errors/occurrences/_backtrace_line.html.erb +0 -27
  105. data/test/tmp/app/views/solid_errors/occurrences/_collection.html.erb +0 -80
  106. data/test/tmp/app/views/solid_errors/occurrences/_occurrence.html.erb +0 -129
  107. data/test/tmp/bin/failures +0 -37
  108. data/test/tmp/bin/failures-mcp +0 -21
  109. data/test/tmp/config/initializers/solid_errors.rb +0 -12
@@ -4,5 +4,4 @@ class BackstageController < ActionController::Base
4
4
  helper MaquinaComponents::IconsHelper
5
5
  helper MaquinaComponents::EmptyHelper
6
6
  helper MaquinaComponentsHelper
7
- helper SolidErrorsHelper
8
7
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Admin overview — a backstage surface alongside the SolidErrors and Mission
4
+ # Control Jobs engines, under the same backstage Basic auth.
5
+ #
6
+ # Out of the box it shows the installed engine entry points and an empty metrics
7
+ # area. Populate @metrics in #index with aggregate, non-sensitive counts for
8
+ # your app — counts, not names or per-record detail.
9
+ class BackstageDashboardController < BackstageController
10
+ layout "admin"
11
+
12
+ before_action :require_backstage_credentials
13
+ before_action :authenticate_backstage
14
+
15
+ def index
16
+ @generated_at = Time.current
17
+
18
+ # Add overview metrics here. Each entry renders as a card:
19
+ #
20
+ # @metrics = [
21
+ # {label: "Accounts", icon: :folder, value: Account.count, hint: "tenants"},
22
+ # {label: "Users", icon: :user, value: User.count, hint: "members"}
23
+ # ]
24
+ #
25
+ # Keep them aggregate-only. :total and :hint are optional.
26
+ @metrics = []
27
+ end
28
+
29
+ private
30
+
31
+ # Closed (503) until credentials.backstage is set. Without this an unset
32
+ # username and password are both "", and an empty Basic header matches them.
33
+ def require_backstage_credentials
34
+ backstage = Rails.application.credentials.backstage || {}
35
+ head :service_unavailable if backstage[:username].blank? || backstage[:password].blank?
36
+ end
37
+
38
+ # Same backstage credentials the engines use.
39
+ def authenticate_backstage
40
+ authenticate_or_request_with_http_basic("Admin") do |username, password|
41
+ backstage = Rails.application.credentials.backstage || {}
42
+
43
+ ActiveSupport::SecurityUtils.secure_compare(username.to_s, backstage[:username].to_s) &
44
+ ActiveSupport::SecurityUtils.secure_compare(password.to_s, backstage[:password].to_s)
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,36 @@
1
+ # One refused request, as Rack::Attack saw it. Rows are written by the
2
+ # `rack.attack` subscriber in config/initializers/rack_attack_events.rb and never
3
+ # updated — the table is the record of who was throttled or blocked, which
4
+ # the middleware otherwise leaves nowhere (it answers ahead of Rails, so the
5
+ # request log never sees these, and the cache's counters expire in minutes).
6
+ #
7
+ # `kind` is decided at write time: a throttle is `throttled`; a scanner path is
8
+ # `blocked` until the address's ban is written (Fail2Ban's third strike within
9
+ # its window), after which every refused request from it is `banned` — so the
10
+ # set of banned addresses is exact, not inferred from counts.
11
+ class Security::AbuseEvent < Security::Record
12
+ # Read from here everywhere the retention is stated (config/recurring.yml
13
+ # sweeps `expired`), so the promise and the clock cannot drift.
14
+ RETENTION = 30.days
15
+
16
+ KINDS = %w[throttled blocked banned].freeze
17
+
18
+ PATH_LIMIT = 1024
19
+ USER_AGENT_LIMIT = 512
20
+
21
+ validates :kind, inclusion: {in: KINDS}
22
+ validates :rule, :ip, :request_method, :path, presence: true
23
+
24
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
25
+ scope :within, ->(window) { where(created_at: window.ago..) }
26
+ scope :expired, -> { where(created_at: ...RETENTION.ago) }
27
+ scope :throttled, -> { where(kind: "throttled") }
28
+ scope :blocked, -> { where(kind: %w[blocked banned]) }
29
+ scope :banned, -> { where(kind: "banned") }
30
+
31
+ def self.record!(kind:, rule:, ip:, request_method:, path:, host: nil, user_agent: nil)
32
+ create!(kind:, rule:, ip:, request_method:, host:,
33
+ path: path.to_s.truncate(PATH_LIMIT, omission: ""),
34
+ user_agent: user_agent&.truncate(USER_AGENT_LIMIT, omission: ""))
35
+ end
36
+ end
@@ -0,0 +1,112 @@
1
+ # What the backstage Security page shows: the last `window` of Rack::Attack refusals,
2
+ # summarised to the top `limit` of each thing worth knowing. A briefing, not a
3
+ # log viewer — no filters, no pagination; the recent list is the drill-down.
4
+ #
5
+ # `banned:` answers whether an address is banned right now, and `scanner_path:`
6
+ # whether a path is one the scanner ban counts. Both default to Rack::Attack's
7
+ # own answer and are kwargs so a test can hand in a Set.
8
+ class Security::AbuseReport
9
+ Address = Data.define(:ip, :count, :rules, :last_seen)
10
+ BlockedAddress = Data.define(:ip, :count, :first_banned_at, :banned_now) do
11
+ alias_method :banned_now?, :banned_now
12
+
13
+ def banned? = first_banned_at.present?
14
+ end
15
+ Path = Data.define(:path, :count, :addresses)
16
+ Host = Data.define(:host, :count)
17
+ Day = Data.define(:date, :throttled, :blocked, :banned) do
18
+ def total = throttled + blocked + banned
19
+ end
20
+
21
+ RECENT_LIMIT = 20
22
+ SIGN_IN_RULE = "login/ip"
23
+
24
+ attr_reader :window, :limit
25
+
26
+ def initialize(window: 7.days, limit: 5, banned: ->(ip) { Rack::Attack.banned?(ip) },
27
+ scanner_path: ->(path) { Rack::Attack.scanner_path?(path) })
28
+ @window = window
29
+ @limit = limit
30
+ @banned = banned
31
+ @scanner_path = scanner_path
32
+ end
33
+
34
+ def events = Security::AbuseEvent.within(window)
35
+
36
+ def empty? = events.none?
37
+
38
+ def total = events.count
39
+
40
+ def throttled_total = events.throttled.count
41
+
42
+ def blocked_total = events.blocked.count
43
+
44
+ def addresses_total = events.distinct.count(:ip)
45
+
46
+ # Addresses the general and sign-in throttles refused, busiest first.
47
+ def throttled_addresses = addresses(events.throttled)
48
+
49
+ # Addresses refused on the sign-in path — the throttle with a security
50
+ # meaning, kept apart so scanner noise never buries it.
51
+ def sign_in_throttles = addresses(events.where(rule: SIGN_IN_RULE))
52
+
53
+ # Addresses a blocklist refused, busiest first: the banned ones with when the
54
+ # ban was written, and those still short of a ban (a strike or two on the
55
+ # scanner rule) without it.
56
+ def blocked_addresses
57
+ events.blocked.group(:ip).order(Arel.sql("COUNT(*) DESC, MAX(created_at) DESC")).limit(limit)
58
+ .pluck(:ip, Arel.sql("COUNT(*)"), Arel.sql("MIN(CASE WHEN kind = 'banned' THEN created_at END)"))
59
+ .map { |ip, count, first| BlockedAddress.new(ip:, count:, first_banned_at: parse_time(first), banned_now: @banned.call(ip)) }
60
+ end
61
+
62
+ # What the scanners were after. A banned address is refused on every path,
63
+ # its ordinary requests (`/`, `/up`) included, so the paths are kept to the
64
+ # ones the scanner rule counts. That is a Ruby test, not SQL, so every blocked
65
+ # path is read and the first `limit` scanner paths kept.
66
+ def scanner_paths
67
+ events.blocked.group(:path).order(Arel.sql("COUNT(*) DESC, path ASC"))
68
+ .pluck(:path, Arel.sql("COUNT(*)"), Arel.sql("COUNT(DISTINCT ip)"))
69
+ .select { |path, _, _| @scanner_path.call(path) }.first(limit)
70
+ .map { |path, count, addresses| Path.new(path:, count:, addresses:) }
71
+ end
72
+
73
+ # Which hostnames took the refusals.
74
+ def targeted_hosts
75
+ events.where.not(host: nil).group(:host).order(Arel.sql("COUNT(*) DESC, host ASC")).limit(limit)
76
+ .pluck(:host, Arel.sql("COUNT(*)"))
77
+ .map { |host, count| Host.new(host:, count:) }
78
+ end
79
+
80
+ # One row per day of the window, today last, zeros kept so a spike reads
81
+ # against its neighbours.
82
+ def by_day
83
+ counts = events.group(:kind, Arel.sql("date(created_at)")).count
84
+ days = (window.ago.to_date..Date.current).to_a.last((window / 1.day).ceil)
85
+
86
+ days.map do |date|
87
+ key = date.iso8601
88
+ Day.new(date:, throttled: counts.fetch(["throttled", key], 0),
89
+ blocked: counts.fetch(["blocked", key], 0), banned: counts.fetch(["banned", key], 0))
90
+ end
91
+ end
92
+
93
+ def recent = events.recent.limit(RECENT_LIMIT)
94
+
95
+ private
96
+
97
+ # The rules come from a second query rather than GROUP_CONCAT, which only
98
+ # SQLite and MySQL spell that way.
99
+ def addresses(scope)
100
+ rows = scope.group(:ip).order(Arel.sql("COUNT(*) DESC, MAX(created_at) DESC")).limit(limit)
101
+ .pluck(:ip, Arel.sql("COUNT(*)"), Arel.sql("MAX(created_at)"))
102
+ rules = scope.where(ip: rows.map(&:first)).distinct.order(:rule).pluck(:ip, :rule)
103
+ .group_by(&:first).transform_values { |pairs| pairs.map(&:last) }
104
+
105
+ rows.map { |ip, count, last| Address.new(ip:, count:, rules: rules.fetch(ip, []), last_seen: parse_time(last)) }
106
+ end
107
+
108
+ # SQLite hands an aggregate over a datetime column back as a string.
109
+ def parse_time(value)
110
+ value.is_a?(String) ? Time.zone.parse(value) : value
111
+ end
112
+ end
@@ -0,0 +1,6 @@
1
+ # Base for everything in the `security` database (db/security_schema.rb).
2
+ class Security::Record < ActiveRecord::Base
3
+ self.abstract_class = true
4
+
5
+ connects_to database: {writing: :security}
6
+ end
@@ -0,0 +1,37 @@
1
+ <%# locals: (title:, description:, addresses:, empty:) %>
2
+ <%= render "components/card" do %>
3
+ <%= render "components/card/header" do %>
4
+ <%= render "components/card/title", text: title %>
5
+ <%= render "components/card/description", text: description %>
6
+ <% end %>
7
+ <%= render "components/card/content", spacing: :full do %>
8
+ <% if addresses.empty? %>
9
+ <%= empty_state(title: empty, icon: :check_circle, size: :compact) %>
10
+ <% else %>
11
+ <div class="overflow-x-auto">
12
+ <%= render "components/table", container: false, "aria-label": title do %>
13
+ <%= render "components/table/header" do %>
14
+ <%= render "components/table/row" do %>
15
+ <%= render "components/table/head", text: "Address" %>
16
+ <%= render "components/table/head", text: "Refused", css_classes: "text-right" %>
17
+ <%= render "components/table/head", text: "Rules" %>
18
+ <%= render "components/table/head", text: "Last seen" %>
19
+ <% end %>
20
+ <% end %>
21
+ <%= render "components/table/body" do %>
22
+ <% addresses.each do |address| %>
23
+ <%= render "components/table/row" do %>
24
+ <%= render "components/table/cell", css_classes: "font-mono", text: address.ip %>
25
+ <%= render "components/table/cell", css_classes: "text-right tabular-nums", text: number_with_delimiter(address.count) %>
26
+ <%= render "components/table/cell", text: address.rules.join(", ") %>
27
+ <%= render "components/table/cell" do %>
28
+ <%= render "backstage/security/utc_time", time: address.last_seen %>
29
+ <% end %>
30
+ <% end %>
31
+ <% end %>
32
+ <% end %>
33
+ <% end %>
34
+ </div>
35
+ <% end %>
36
+ <% end %>
37
+ <% end %>
@@ -0,0 +1,53 @@
1
+ <%# locals: (addresses:) %>
2
+ <%# "Banned now" is Rack::Attack's own answer at render time; the rows are the
3
+ refusals recorded by a blocklist, so an expired ban still shows here for
4
+ the week with its badge flipped, and an address short of a ban shows as
5
+ not banned. %>
6
+ <%= render "components/card" do %>
7
+ <%= render "components/card/header" do %>
8
+ <%= render "components/card/title", text: "Blocked addresses" %>
9
+ <%= render "components/card/description", text: "#{Rack::Attack::SCANNER_MAXRETRY} scanner paths in #{Rack::Attack::SCANNER_FINDTIME.inspect} bans an address for #{Rack::Attack::SCANNER_BANTIME.inspect}; #{Rack::Attack::FLOOD_MAXRETRY} requests in #{Rack::Attack::GENERAL_PERIOD.inspect}, for #{Rack::Attack::FLOOD_BANTIME.inspect}." %>
10
+ <% end %>
11
+ <%= render "components/card/content", spacing: :full do %>
12
+ <% if addresses.empty? %>
13
+ <%= empty_state(title: "No address has been blocked.", icon: :check_circle, size: :compact) %>
14
+ <% else %>
15
+ <div class="overflow-x-auto">
16
+ <%= render "components/table", container: false, "aria-label": "Blocked addresses" do %>
17
+ <%= render "components/table/header" do %>
18
+ <%= render "components/table/row" do %>
19
+ <%= render "components/table/head", text: "Address" %>
20
+ <%= render "components/table/head", text: "Refused", css_classes: "text-right" %>
21
+ <%= render "components/table/head", text: "Banned" %>
22
+ <%= render "components/table/head", text: "Since" %>
23
+ <% end %>
24
+ <% end %>
25
+ <%= render "components/table/body" do %>
26
+ <% addresses.each do |address| %>
27
+ <%= render "components/table/row" do %>
28
+ <%= render "components/table/cell", css_classes: "font-mono", text: address.ip %>
29
+ <%= render "components/table/cell", css_classes: "text-right tabular-nums", text: number_with_delimiter(address.count) %>
30
+ <%= render "components/table/cell" do %>
31
+ <% if address.banned_now? %>
32
+ <%= render "components/badge", variant: :destructive, size: :sm do %>Banned now<% end %>
33
+ <% elsif address.banned? %>
34
+ <%= render "components/badge", variant: :outline, size: :sm do %>Expired<% end %>
35
+ <% else %>
36
+ <%= render "components/badge", variant: :secondary, size: :sm do %>Not banned<% end %>
37
+ <% end %>
38
+ <% end %>
39
+ <%= render "components/table/cell" do %>
40
+ <% if address.banned? %>
41
+ <%= render "backstage/security/utc_time", time: address.first_banned_at %>
42
+ <% else %>
43
+ <span class="text-muted-foreground">—</span>
44
+ <% end %>
45
+ <% end %>
46
+ <% end %>
47
+ <% end %>
48
+ <% end %>
49
+ <% end %>
50
+ </div>
51
+ <% end %>
52
+ <% end %>
53
+ <% end %>
@@ -0,0 +1,33 @@
1
+ <%# locals: (days:) %>
2
+ <%= render "components/card" do %>
3
+ <%= render "components/card/header" do %>
4
+ <%= render "components/card/title", text: "By day" %>
5
+ <%= render "components/card/description", text: "Zeros kept, so a spike reads against its neighbours." %>
6
+ <% end %>
7
+ <%= render "components/card/content", spacing: :full do %>
8
+ <div class="overflow-x-auto">
9
+ <%= render "components/table", container: false, "aria-label": "Refusals by day" do %>
10
+ <%= render "components/table/header" do %>
11
+ <%= render "components/table/row" do %>
12
+ <%= render "components/table/head", text: "Day" %>
13
+ <%= render "components/table/head", text: "Throttled", css_classes: "text-right" %>
14
+ <%= render "components/table/head", text: "Blocked", css_classes: "text-right" %>
15
+ <%= render "components/table/head", text: "Banned", css_classes: "text-right" %>
16
+ <% end %>
17
+ <% end %>
18
+ <%= render "components/table/body" do %>
19
+ <% days.each do |day| %>
20
+ <%= render "components/table/row" do %>
21
+ <%= render "components/table/cell" do %>
22
+ <time datetime="<%= day.date.iso8601 %>"><%= day.date.strftime("%a %-d %b") %></time>
23
+ <% end %>
24
+ <%= render "components/table/cell", css_classes: "text-right tabular-nums", text: number_with_delimiter(day.throttled) %>
25
+ <%= render "components/table/cell", css_classes: "text-right tabular-nums", text: number_with_delimiter(day.blocked) %>
26
+ <%= render "components/table/cell", css_classes: "text-right tabular-nums", text: number_with_delimiter(day.banned) %>
27
+ <% end %>
28
+ <% end %>
29
+ <% end %>
30
+ <% end %>
31
+ </div>
32
+ <% end %>
33
+ <% end %>
@@ -0,0 +1,31 @@
1
+ <%# locals: (hosts:) %>
2
+ <%= render "components/card" do %>
3
+ <%= render "components/card/header" do %>
4
+ <%= render "components/card/title", text: "Targeted hosts" %>
5
+ <%= render "components/card/description", text: "Which hostnames the refused requests were aimed at." %>
6
+ <% end %>
7
+ <%= render "components/card/content", spacing: :full do %>
8
+ <% if hosts.empty? %>
9
+ <%= empty_state(title: "No host recorded.", icon: :check_circle, size: :compact) %>
10
+ <% else %>
11
+ <div class="overflow-x-auto">
12
+ <%= render "components/table", container: false, "aria-label": "Targeted hosts" do %>
13
+ <%= render "components/table/header" do %>
14
+ <%= render "components/table/row" do %>
15
+ <%= render "components/table/head", text: "Host" %>
16
+ <%= render "components/table/head", text: "Refused", css_classes: "text-right" %>
17
+ <% end %>
18
+ <% end %>
19
+ <%= render "components/table/body" do %>
20
+ <% hosts.each do |host| %>
21
+ <%= render "components/table/row" do %>
22
+ <%= render "components/table/cell", css_classes: "font-mono", text: host.host %>
23
+ <%= render "components/table/cell", css_classes: "text-right tabular-nums", text: number_with_delimiter(host.count) %>
24
+ <% end %>
25
+ <% end %>
26
+ <% end %>
27
+ <% end %>
28
+ </div>
29
+ <% end %>
30
+ <% end %>
31
+ <% end %>
@@ -0,0 +1,33 @@
1
+ <%# locals: (paths:) %>
2
+ <%= render "components/card" do %>
3
+ <%= render "components/card/header" do %>
4
+ <%= render "components/card/title", text: "Most requested blocked paths" %>
5
+ <%= render "components/card/description", text: "What the scanners were after." %>
6
+ <% end %>
7
+ <%= render "components/card/content", spacing: :full do %>
8
+ <% if paths.empty? %>
9
+ <%= empty_state(title: "No scanner path was requested.", icon: :check_circle, size: :compact) %>
10
+ <% else %>
11
+ <div class="overflow-x-auto">
12
+ <%= render "components/table", container: false, "aria-label": "Most requested blocked paths" do %>
13
+ <%= render "components/table/header" do %>
14
+ <%= render "components/table/row" do %>
15
+ <%= render "components/table/head", text: "Path" %>
16
+ <%= render "components/table/head", text: "Requests", css_classes: "text-right" %>
17
+ <%= render "components/table/head", text: "Addresses", css_classes: "text-right" %>
18
+ <% end %>
19
+ <% end %>
20
+ <%= render "components/table/body" do %>
21
+ <% paths.each do |path| %>
22
+ <%= render "components/table/row" do %>
23
+ <%= render "components/table/cell", css_classes: "font-mono whitespace-normal break-all", text: path.path %>
24
+ <%= render "components/table/cell", css_classes: "text-right tabular-nums", text: number_with_delimiter(path.count) %>
25
+ <%= render "components/table/cell", css_classes: "text-right tabular-nums", text: number_with_delimiter(path.addresses) %>
26
+ <% end %>
27
+ <% end %>
28
+ <% end %>
29
+ <% end %>
30
+ </div>
31
+ <% end %>
32
+ <% end %>
33
+ <% end %>
@@ -0,0 +1,37 @@
1
+ <%# locals: (events:) %>
2
+ <%= render "components/card" do %>
3
+ <%= render "components/card/header" do %>
4
+ <%= render "components/card/title", text: "Recent refusals" %>
5
+ <%= render "components/card/description", text: "The last #{Security::AbuseReport::RECENT_LIMIT}, newest first." %>
6
+ <% end %>
7
+ <%= render "components/card/content", spacing: :full do %>
8
+ <div class="overflow-x-auto">
9
+ <%= render "components/table", container: false, "aria-label": "Recent refusals" do %>
10
+ <%= render "components/table/header" do %>
11
+ <%= render "components/table/row" do %>
12
+ <%= render "components/table/head", text: "When" %>
13
+ <%= render "components/table/head", text: "Kind" %>
14
+ <%= render "components/table/head", text: "Address" %>
15
+ <%= render "components/table/head", text: "Request" %>
16
+ <% end %>
17
+ <% end %>
18
+ <%= render "components/table/body" do %>
19
+ <% events.each do |event| %>
20
+ <%= render "components/table/row" do %>
21
+ <%= render "components/table/cell" do %>
22
+ <%= render "backstage/security/utc_time", time: event.created_at %>
23
+ <% end %>
24
+ <%= render "components/table/cell" do %>
25
+ <%= render "components/badge", variant: {"banned" => :destructive, "blocked" => :warning}.fetch(event.kind, :secondary), size: :sm do %><%= event.kind %><% end %>
26
+ <% end %>
27
+ <%= render "components/table/cell", css_classes: "font-mono", text: event.ip %>
28
+ <%= render "components/table/cell", css_classes: "font-mono whitespace-normal break-all" do %>
29
+ <span class="text-muted-foreground"><%= event.request_method %></span> <%= event.path %>
30
+ <% end %>
31
+ <% end %>
32
+ <% end %>
33
+ <% end %>
34
+ <% end %>
35
+ </div>
36
+ <% end %>
37
+ <% end %>
@@ -0,0 +1,2 @@
1
+ <%# locals: (time:) %>
2
+ <time datetime="<%= time.utc.iso8601 %>" title="<%= time.utc.iso8601 %>" class="whitespace-nowrap tabular-nums"><%= time.utc.strftime("%-d %b %H:%M") %></time>
@@ -0,0 +1,47 @@
1
+ <% content_for :title, "Security" %>
2
+ <%# Rack::Attack's last week, top five of each. Every number is read from
3
+ Security::AbuseEvent through Security::AbuseReport; the page holds no
4
+ arithmetic of its own. Times are UTC: a log reads better in one clock. %>
5
+ <div class="py-8 space-y-8">
6
+ <div>
7
+ <h1 class="text-2xl text-foreground">Security</h1>
8
+ <p class="mt-1 text-sm text-muted-foreground">
9
+ Requests Rack::Attack refused in the last <%= @report.window.inspect %>, kept for <%= Security::AbuseEvent::RETENTION.inspect %>. Times are UTC.
10
+ </p>
11
+ </div>
12
+
13
+ <% if @report.empty? %>
14
+ <%= empty_state(title: "Nothing refused this week",
15
+ description: "No request has been throttled or blocked in the last #{@report.window.inspect}.",
16
+ icon: :check_circle, size: :default) %>
17
+ <% else %>
18
+ <%# Built-in icon names only: the stats card renders through
19
+ `builtin_icon_for`, which never consults the app's override set. %>
20
+ <%= render "components/stats/stats_grid", columns: 4, cards: [
21
+ {title: "Refused requests", value: number_with_delimiter(@report.total), icon: :slash},
22
+ {title: "Throttled", value: number_with_delimiter(@report.throttled_total), icon: :clock},
23
+ {title: "Blocked or banned", value: number_with_delimiter(@report.blocked_total), icon: :triangle_alert},
24
+ {title: "Addresses", value: number_with_delimiter(@report.addresses_total), icon: :users}
25
+ ] %>
26
+
27
+ <div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
28
+ <%= render "backstage/security/addresses", title: "Throttled addresses",
29
+ description: "Over the general and sign-in limits, busiest first.",
30
+ addresses: @report.throttled_addresses, empty: "No address was throttled." %>
31
+
32
+ <%= render "backstage/security/blocked", addresses: @report.blocked_addresses %>
33
+
34
+ <%= render "backstage/security/paths", paths: @report.scanner_paths %>
35
+
36
+ <%= render "backstage/security/addresses", title: "Sign-in throttles",
37
+ description: "Addresses refused on POST #{Rack::Attack::LOGIN_PATH} — #{Rack::Attack::LOGIN_LIMIT} attempts in #{Rack::Attack::LOGIN_PERIOD.inspect}.",
38
+ addresses: @report.sign_in_throttles, empty: "No one has been throttled on sign-in." %>
39
+
40
+ <%= render "backstage/security/hosts", hosts: @report.targeted_hosts %>
41
+
42
+ <%= render "backstage/security/by_day", days: @report.by_day %>
43
+ </div>
44
+
45
+ <%= render "backstage/security/recent", events: @report.recent %>
46
+ <% end %>
47
+ </div>
@@ -0,0 +1,93 @@
1
+ <div class="py-8">
2
+ <!-- Header -->
3
+ <div class="mb-6 flex items-end justify-between border-b border-border pb-4">
4
+ <h1 class="text-2xl font-bold text-foreground">Overview</h1>
5
+ <%% if @generated_at %>
6
+ <p class="font-mono text-[11px] tabular-nums text-muted-foreground">as of <%%= @generated_at.strftime("%H:%M %Z") %></p>
7
+ <%% end %>
8
+ </div>
9
+
10
+ <!-- Metrics: populate @metrics in BackstageDashboardController#index. Aggregate-only. -->
11
+ <%% if @metrics.present? %>
12
+ <div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
13
+ <%% @metrics.each do |metric| %>
14
+ <%%= render "components/card" do %>
15
+ <div class="px-5 py-4">
16
+ <div class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
17
+ <%% if metric[:icon] %><%%= icon_for(metric[:icon], class: "size-3.5") %><%% end %>
18
+ <%%= metric[:label] %>
19
+ </div>
20
+ <div class="font-display text-4xl font-semibold tabular-nums text-foreground">
21
+ <%%= number_with_delimiter(metric[:value]) %><%% if metric[:total] %><span class="text-xl text-muted-foreground">/<%%= number_with_delimiter(metric[:total]) %></span><%% end %>
22
+ </div>
23
+ <%% if metric[:hint] %>
24
+ <div class="mt-1 text-[11px] tabular-nums text-muted-foreground"><%%= metric[:hint] %></div>
25
+ <%% end %>
26
+ </div>
27
+ <%% end %>
28
+ <%% end %>
29
+ </div>
30
+ <%% else %>
31
+ <%%= empty_state(
32
+ title: "No metrics yet",
33
+ description: "Add aggregate counts in BackstageDashboardController#index to populate this overview.",
34
+ icon: :layout_dashboard,
35
+ size: :default
36
+ ) %>
37
+ <%% end %>
38
+
39
+ <!-- Operations: entry points to the installed engines -->
40
+ <h2 class="mt-10 mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Operations</h2>
41
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
42
+ <%% if defined?(SolidErrors::Engine) %>
43
+ <%%= link_to "<%= options[:prefix] %>/errors", class: "group block" do %>
44
+ <%%= render "components/card", css_classes: "transition-shadow hover:shadow-md" do %>
45
+ <div class="flex items-center gap-4 px-5 py-4">
46
+ <span class="inline-flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
47
+ <%%= icon_for(:triangle_alert, class: "size-5") %>
48
+ </span>
49
+ <div class="min-w-0 flex-1">
50
+ <div class="font-semibold text-foreground">Errors</div>
51
+ <div class="text-sm text-muted-foreground">Exceptions, backtraces, and resolution</div>
52
+ </div>
53
+ <%%= icon_for(:arrow_right, class: "size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5") %>
54
+ </div>
55
+ <%% end %>
56
+ <%% end %>
57
+ <%% end %>
58
+
59
+ <%% if defined?(MissionControl::Jobs::Engine) %>
60
+ <%%= link_to "<%= options[:prefix] %>/jobs", class: "group block" do %>
61
+ <%%= render "components/card", css_classes: "transition-shadow hover:shadow-md" do %>
62
+ <div class="flex items-center gap-4 px-5 py-4">
63
+ <span class="inline-flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
64
+ <%%= icon_for(:inbox, class: "size-5") %>
65
+ </span>
66
+ <div class="min-w-0 flex-1">
67
+ <div class="font-semibold text-foreground">Background jobs</div>
68
+ <div class="text-sm text-muted-foreground">Queues, workers, and recurring tasks</div>
69
+ </div>
70
+ <%%= icon_for(:arrow_right, class: "size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5") %>
71
+ </div>
72
+ <%% end %>
73
+ <%% end %>
74
+ <%% end %>
75
+
76
+ <%% if defined?(Security::AbuseEvent) %>
77
+ <%%= link_to "<%= options[:prefix] %>/security", class: "group block" do %>
78
+ <%%= render "components/card", css_classes: "transition-shadow hover:shadow-md" do %>
79
+ <div class="flex items-center gap-4 px-5 py-4">
80
+ <span class="inline-flex size-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
81
+ <%%= icon_for(:slash, class: "size-5") %>
82
+ </span>
83
+ <div class="min-w-0 flex-1">
84
+ <div class="font-semibold text-foreground">Security</div>
85
+ <div class="text-sm text-muted-foreground">Throttled, blocked, and banned requests</div>
86
+ </div>
87
+ <%%= icon_for(:arrow_right, class: "size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5") %>
88
+ </div>
89
+ <%% end %>
90
+ <%% end %>
91
+ <%% end %>
92
+ </div>
93
+ </div>
@@ -0,0 +1,40 @@
1
+ <nav class="border-b border-border bg-background">
2
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
3
+ <div class="flex items-center justify-between h-12">
4
+ <div class="flex items-center gap-6">
5
+ <span class="text-sm font-semibold text-foreground">Admin</span>
6
+ <div class="-mb-px flex items-center space-x-4">
7
+ <%%= link_to "<%= options[:prefix] %>",
8
+ class: "inline-flex items-center gap-1.5 whitespace-nowrap px-1 py-3 text-sm font-medium #{request.path == '<%= options[:prefix] %>' ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'}" do %>
9
+ <%%= icon_for(:layout_dashboard, class: "size-4") %>
10
+ <span>Overview</span>
11
+ <%% end %>
12
+
13
+ <%%= link_to "<%= options[:prefix] %>/errors",
14
+ class: "inline-flex items-center gap-1.5 whitespace-nowrap px-1 py-3 text-sm font-medium #{request.path.start_with?('<%= options[:prefix] %>/errors') ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'}" do %>
15
+ <%%= icon_for(:triangle_alert, class: "size-4") %>
16
+ <span>Errors</span>
17
+ <%% end %>
18
+
19
+ <%%= link_to "<%= options[:prefix] %>/jobs",
20
+ class: "inline-flex items-center gap-1.5 whitespace-nowrap px-1 py-3 text-sm font-medium #{request.path.start_with?('<%= options[:prefix] %>/jobs') ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'}" do %>
21
+ <%%= icon_for(:inbox, class: "size-4") %>
22
+ <span>Jobs</span>
23
+ <%% end %>
24
+
25
+ <%%= link_to "<%= options[:prefix] %>/security",
26
+ class: "inline-flex items-center gap-1.5 whitespace-nowrap px-1 py-3 text-sm font-medium #{request.path.start_with?('<%= options[:prefix] %>/security') ? 'border-b-2 border-primary text-primary' : 'text-muted-foreground hover:text-foreground'}" do %>
27
+ <%%= icon_for(:slash, class: "size-4") %>
28
+ <span>Security</span>
29
+ <%% end %>
30
+ </div>
31
+ </div>
32
+
33
+ <%%= link_to main_app.root_path,
34
+ class: "inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors" do %>
35
+ <%%= icon_for(:arrow_left, class: "size-4") %>
36
+ Back to app
37
+ <%% end %>
38
+ </div>
39
+ </div>
40
+ </nav>