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.
- checksums.yaml +4 -4
- data/README.md +125 -15
- data/lib/generators/maquina/app/app_generator.rb +9 -4
- data/lib/generators/maquina/app/templates/README.md.erb +6 -4
- data/lib/generators/maquina/app/templates/app/views/home/index.html.erb +18 -5
- data/lib/generators/maquina/app/templates/config/database.yml.tt +12 -0
- data/lib/generators/maquina/clave/clave_generator.rb +48 -0
- data/lib/generators/maquina/clave/templates/app/controllers/registration/verifications_controller.rb.tt +10 -1
- data/lib/generators/maquina/clave/templates/app/controllers/session/verifications_controller.rb.tt +10 -1
- data/lib/generators/maquina/clave/templates/app/models/email_verification.rb.tt +18 -0
- data/lib/generators/maquina/clave/templates/config/locales/clave.en.yml +2 -0
- data/lib/generators/maquina/clave/templates/config/locales/clave.es.yml +2 -0
- data/lib/generators/maquina/csp_reports/USAGE +18 -0
- data/lib/generators/maquina/csp_reports/csp_reports_generator.rb +231 -0
- data/lib/generators/maquina/csp_reports/templates/app/controllers/csp_reports_controller.rb +24 -0
- data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation.rb +89 -0
- data/lib/generators/maquina/csp_reports/templates/app/models/security/csp_violation_report.rb +30 -0
- data/lib/generators/maquina/csp_reports/templates/app/views/backstage/security/_csp_violations.html.erb +42 -0
- data/lib/generators/maquina/csp_reports/templates/db/create_csp_violations.rb.tt +23 -0
- data/lib/generators/maquina/mission_control_jobs/mission_control_jobs_generator.rb +32 -1
- data/lib/generators/maquina/mission_control_jobs/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
- data/lib/generators/maquina/mission_control_jobs/templates/app/views/layouts/admin.html.erb +21 -0
- data/lib/generators/maquina/rack_attack/USAGE +8 -6
- data/lib/generators/maquina/rack_attack/rack_attack_generator.rb +6 -2
- data/lib/generators/maquina/rack_attack/templates/config/initializers/rack_attack.rb.tt +139 -57
- data/lib/generators/maquina/security/USAGE +15 -0
- data/lib/generators/maquina/security/security_generator.rb +278 -0
- data/lib/generators/maquina/security/templates/app/controllers/backstage/security_controller.rb.tt +45 -0
- data/{test/tmp/app/controllers/backstage_controller.rb → lib/generators/maquina/security/templates/app/controllers/backstage_controller.rb.tt} +0 -1
- data/lib/generators/maquina/security/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
- data/lib/generators/maquina/security/templates/app/models/security/abuse_event.rb +36 -0
- data/lib/generators/maquina/security/templates/app/models/security/abuse_report.rb +112 -0
- data/lib/generators/maquina/security/templates/app/models/security/record.rb +6 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_addresses.html.erb +37 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_blocked.html.erb +53 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_by_day.html.erb +33 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_hosts.html.erb +31 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_paths.html.erb +33 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_recent.html.erb +37 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/_utc_time.html.erb +2 -0
- data/lib/generators/maquina/security/templates/app/views/backstage/security/show.html.erb +47 -0
- data/lib/generators/maquina/security/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
- data/lib/generators/maquina/security/templates/app/views/layouts/_admin_navigation.html.erb.tt +40 -0
- data/lib/generators/maquina/security/templates/app/views/layouts/admin.html.erb +21 -0
- 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
- data/lib/generators/maquina/security/templates/config/initializers/rack_attack_events.rb +34 -0
- data/lib/generators/maquina/security/templates/db/security_schema.rb.tt +27 -0
- data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +32 -1
- data/lib/generators/maquina/solid_errors/templates/app/controllers/backstage_dashboard_controller.rb +47 -0
- data/lib/generators/maquina/solid_errors/templates/app/views/backstage_dashboard/index.html.erb.tt +93 -0
- data/lib/generators/maquina/solid_errors/templates/app/views/layouts/_admin_navigation.html.erb.tt +18 -6
- data/lib/generators/maquina/solid_errors/templates/app/views/layouts/admin.html.erb +21 -0
- data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.html.erb +65 -0
- data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/error_mailer/error_occurred.text.erb +30 -0
- data/lib/generators/maquina/solid_errors/templates/config/initializers/solid_errors.rb.tt +12 -0
- data/lib/maquina_generators/version.rb +1 -1
- data/test/generators/maquina/app_generator_test.rb +21 -0
- data/test/generators/maquina/clave_generator_test.rb +74 -0
- data/test/generators/maquina/csp_reports_generator_test.rb +151 -0
- data/test/generators/maquina/mission_control_jobs_generator_test.rb +28 -4
- data/test/generators/maquina/rack_attack_generator_test.rb +41 -19
- data/test/generators/maquina/security_generator_test.rb +380 -0
- data/test/generators/maquina/solid_errors_generator_test.rb +59 -4
- data/test/tmp/.gitignore +5 -0
- data/test/tmp/.rubocop.yml +11 -0
- data/test/tmp/.standard.yml +1 -0
- data/test/tmp/Gemfile +12 -2
- data/test/tmp/Procfile.dev +3 -0
- data/test/tmp/README.md +89 -0
- data/test/tmp/app/controllers/home_controller.rb +6 -0
- data/test/tmp/app/javascript/application.js +1 -0
- data/test/tmp/app/views/home/index.html.erb +204 -0
- data/test/tmp/app/views/layouts/application.html.erb +10 -0
- data/test/tmp/config/application.rb +15 -0
- data/test/tmp/config/database.yml +90 -0
- data/test/tmp/config/database.yml.example +90 -0
- data/test/tmp/config/environments/development.rb +8 -0
- data/test/tmp/config/environments/production.rb +6 -0
- data/test/tmp/config/initializers/generators.rb +4 -0
- data/test/tmp/config/routes.rb +1 -1
- metadata +60 -32
- data/test/tmp/app/agents/error_redactor.rb +0 -34
- data/test/tmp/app/agents/errors_query.rb +0 -84
- data/test/tmp/app/agents/failure_triage.rb +0 -21
- data/test/tmp/app/agents/get_exception_tool.rb +0 -26
- data/test/tmp/app/agents/list_failures_tool.rb +0 -20
- data/test/tmp/app/agents/top_exceptions_tool.rb +0 -18
- data/test/tmp/app/helpers/solid_errors_helper.rb +0 -9
- data/test/tmp/app/javascript/controllers/backtrace_filter_controller.js +0 -27
- data/test/tmp/app/javascript/controllers/clipboard_controller.js +0 -36
- data/test/tmp/app/views/layouts/_admin_navigation.html.erb +0 -28
- data/test/tmp/app/views/solid_errors/errors/_actions.html.erb +0 -22
- data/test/tmp/app/views/solid_errors/errors/_delete_button.html.erb +0 -10
- data/test/tmp/app/views/solid_errors/errors/_error_card.html.erb +0 -63
- data/test/tmp/app/views/solid_errors/errors/_resolve_button.html.erb +0 -11
- data/test/tmp/app/views/solid_errors/errors/index.html.erb +0 -37
- data/test/tmp/app/views/solid_errors/errors/show/_actions.html.erb +0 -33
- data/test/tmp/app/views/solid_errors/errors/show/_error_details.html.erb +0 -95
- data/test/tmp/app/views/solid_errors/errors/show/_header.html.erb +0 -18
- data/test/tmp/app/views/solid_errors/errors/show/_properties.html.erb +0 -65
- data/test/tmp/app/views/solid_errors/errors/show.html.erb +0 -23
- data/test/tmp/app/views/solid_errors/occurrences/_backtrace_line.html.erb +0 -27
- data/test/tmp/app/views/solid_errors/occurrences/_collection.html.erb +0 -80
- data/test/tmp/app/views/solid_errors/occurrences/_occurrence.html.erb +0 -129
- data/test/tmp/bin/failures +0 -37
- data/test/tmp/bin/failures-mcp +0 -21
- data/test/tmp/config/initializers/solid_errors.rb +0 -12
data/lib/generators/maquina/security/templates/app/controllers/backstage_dashboard_controller.rb
ADDED
|
@@ -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
|
data/lib/generators/maquina/security/templates/app/views/backstage/security/_addresses.html.erb
ADDED
|
@@ -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,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>
|
data/lib/generators/maquina/security/templates/app/views/backstage_dashboard/index.html.erb.tt
ADDED
|
@@ -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>
|
data/lib/generators/maquina/security/templates/app/views/layouts/_admin_navigation.html.erb.tt
ADDED
|
@@ -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>
|