maquina-generators 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -0
  3. data/lib/generators/maquina/rack_attack/USAGE +15 -0
  4. data/lib/generators/maquina/rack_attack/rack_attack_generator.rb +53 -0
  5. data/lib/generators/maquina/rack_attack/templates/config/initializers/rack_attack.rb.tt +95 -0
  6. data/lib/generators/maquina/solid_errors/solid_errors_generator.rb +21 -3
  7. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/_actions.html.erb +39 -0
  8. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/_delete_button.html.erb +24 -0
  9. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/_error_card.html.erb +106 -0
  10. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/_resolve_button.html.erb +23 -0
  11. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/index.html.erb +74 -0
  12. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/show/_actions.html.erb +60 -0
  13. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/show/_error_details.html.erb +132 -0
  14. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/show/_header.html.erb +32 -0
  15. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/show/_properties.html.erb +77 -0
  16. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/errors/show.html.erb +23 -0
  17. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/occurrences/_backtrace_line.html.erb +27 -0
  18. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/occurrences/_collection.html.erb +124 -0
  19. data/lib/generators/maquina/solid_errors/templates/app/views/solid_errors/occurrences/_occurrence.html.erb +175 -0
  20. data/lib/maquina_generators/version.rb +1 -1
  21. data/test/generators/maquina/rack_attack_generator_test.rb +122 -0
  22. data/test/generators/maquina/solid_errors_generator_test.rb +25 -0
  23. data/test/tmp/Gemfile +1 -1
  24. data/test/tmp/config/initializers/rack_attack.rb +95 -0
  25. data/test/tmp/config/routes.rb +0 -1
  26. metadata +19 -3
  27. data/test/tmp/app/controllers/backstage_controller.rb +0 -4
  28. data/test/tmp/config/initializers/solid_errors.rb +0 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 217f011212f8bd0de1e98fe87fe41afeb48d2171eba4431cb82b219a69239a08
4
- data.tar.gz: 927e664fc585adbe4a7d1e3f441a2fa49fb2514fe6ac239e742e240bf913ece3
3
+ metadata.gz: ce5b50ab1d409ff8e66103ffd248866536fb97868694f514fc63e622a091d3e9
4
+ data.tar.gz: 991f0a0f392196b14285b68ec027cf120f4b610f172c6772e095343ff032bf1d
5
5
  SHA512:
6
- metadata.gz: 01b0e678645dca5f7a0a080a9b70d463d0d8da194a3f2fe9ad09d77fa6c0bc673863f4ac525000db990d441508560bee5b2ce3858cb68b61d59432d5bbefcdc8
7
- data.tar.gz: b6277238ae34b753a3056b6f559ffd5bd4324e8e605568d624a79b1dca6b4a70e54a3c22cce5f4405de1848e837a540f12d6b022d6d2e746d0025d5afa3daba6
6
+ metadata.gz: 0f151ba3435ab5cb9be0cf0ddbe8d864b18d3f59f2c403e08a9653998fbe95b384947d315f99bc155d5337603d931b839d1408aa2b227eb41562490e07082d87
7
+ data.tar.gz: 1caf6b0eb21b9eefb9ada1806e6af396fd435224edce1bf7dd1a1899a7243164b1baa599eebb28d91c2d142e26e19bcbe66da8db2032abea937804698ca46c26
data/README.md CHANGED
@@ -69,11 +69,13 @@ All generated code lives in your app -- edit it directly:
69
69
  - **BackstageController:** Inherits from `ActionController::Base` (bypasses app's ApplicationController concerns)
70
70
  - **Initializer:** Credentials-first auth with ENV variable fallback, database connection config
71
71
  - **Route:** Mounts `SolidErrors::Engine` under a configurable prefix
72
+ - **Custom views:** Optionally copies custom Tailwind-styled views to override the gem defaults
72
73
 
73
74
  #### Usage
74
75
 
75
76
  ```bash
76
77
  rails g maquina:solid_errors --prefix /admin
78
+ rails g maquina:solid_errors --prefix /admin --copy-views # Include custom views
77
79
  ```
78
80
 
79
81
  The generator automatically runs `bundle install` and `solid_errors:install`. After running, execute `bin/rails db:migrate`.
@@ -82,6 +84,7 @@ The generator automatically runs `bundle install` and `solid_errors:install`. Af
82
84
 
83
85
  ```bash
84
86
  rails g maquina:solid_errors --prefix /admin # Default env vars
87
+ rails g maquina:solid_errors --prefix /admin --copy-views # With custom views
85
88
  rails g maquina:solid_errors --prefix /backstage \
86
89
  --user-env-var ADMIN_USER --password-env-var ADMIN_PASSWORD # Custom env vars
87
90
  ```
@@ -130,6 +133,33 @@ Credentials are resolved in order:
130
133
 
131
134
  ---
132
135
 
136
+ ### Rack Attack -- Request Protection
137
+
138
+ **Rack Attack** installs the [rack-attack](https://github.com/rack/rack-attack) gem with default security rules to block common vulnerability scans and throttle abusive requests.
139
+
140
+ #### What it generates
141
+
142
+ - **Initializer:** `config/initializers/rack_attack.rb` with blocklists, safelists, and throttles
143
+
144
+ #### Usage
145
+
146
+ ```bash
147
+ rails g maquina:rack_attack
148
+ ```
149
+
150
+ The generator automatically runs `bundle install`.
151
+
152
+ #### Default Protections
153
+
154
+ - **Blocklists:** PHP files (`*.php`), WordPress paths (`wp-admin`, `wp-login`, etc.), sensitive files (`.env`, `.git`, `/etc/passwd`, etc.), scanner targets (`phpmyadmin`, `cgi-bin`, etc.)
155
+ - **Safelists:** Localhost (`127.0.0.1`, `::1`)
156
+ - **Throttles:** 300 requests/5min per IP (general), 5 login attempts/20s per IP
157
+ - **Responses:** 403 Forbidden for blocklisted, 429 Too Many Requests for throttled
158
+
159
+ Customize rules in `config/initializers/rack_attack.rb`.
160
+
161
+ ---
162
+
133
163
  ## Adding New Generators
134
164
 
135
165
  Create a new folder under `lib/generators/maquina/`:
@@ -0,0 +1,15 @@
1
+ Description:
2
+ Installs Rack::Attack with default security rules to block common
3
+ vulnerability scans and throttle abusive requests.
4
+
5
+ Default protections:
6
+ - Blocks PHP file requests (*.php)
7
+ - Blocks WordPress paths (wp-admin, wp-login, wp-content, etc.)
8
+ - Blocks sensitive file access (.env, .git, /etc/passwd, etc.)
9
+ - Blocks common scanner targets (phpmyadmin, cgi-bin, etc.)
10
+ - Safelists localhost
11
+ - Throttles general requests (300/5min per IP)
12
+ - Throttles login attempts (5/20s per IP)
13
+
14
+ Examples:
15
+ rails g maquina:rack_attack
@@ -0,0 +1,53 @@
1
+ require "rails/generators"
2
+
3
+ module Maquina
4
+ module Generators
5
+ class RackAttackGenerator < Rails::Generators::Base
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ # 1. Initializer
9
+ def create_initializer
10
+ template "config/initializers/rack_attack.rb.tt",
11
+ "config/initializers/rack_attack.rb"
12
+ end
13
+
14
+ # 2. Add gem to Gemfile
15
+ def add_gem
16
+ gemfile_path = File.join(destination_root, "Gemfile")
17
+ if File.exist?(gemfile_path)
18
+ content = File.read(gemfile_path)
19
+ unless content.include?('gem "rack-attack"')
20
+ append_to_file "Gemfile", "\ngem \"rack-attack\"\n"
21
+ end
22
+ end
23
+ end
24
+
25
+ # 3. Bundle install
26
+ def run_bundle_install
27
+ return unless rails_app?
28
+
29
+ run "bundle install", capture: true
30
+ end
31
+
32
+ # 4. Post-install message
33
+ def show_post_install
34
+ say ""
35
+ say "Rack::Attack has been installed!", :green
36
+ say ""
37
+ say "Default protections enabled:", :yellow
38
+ say " - Blocklist: PHP files, WordPress paths, sensitive files, scanner targets"
39
+ say " - Safelist: localhost (127.0.0.1, ::1)"
40
+ say " - Throttle: 300 req/5min per IP, 5 login attempts/20s per IP"
41
+ say ""
42
+ say "Customize rules in config/initializers/rack_attack.rb"
43
+ say ""
44
+ end
45
+
46
+ private
47
+
48
+ def rails_app?
49
+ File.exist?(File.join(destination_root, "bin/rails"))
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rack::Attack configuration
4
+ # https://github.com/rack/rack-attack
5
+
6
+ # Safelist localhost
7
+ Rack::Attack.safelist("allow-localhost") do |req|
8
+ req.ip == "127.0.0.1" || req.ip == "::1"
9
+ end
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Blocklists — common vulnerability scanner paths
13
+ # ---------------------------------------------------------------------------
14
+
15
+ # Block PHP file requests
16
+ Rack::Attack.blocklist("block-php") do |req|
17
+ req.path.match?(/\.php\d?$/i)
18
+ end
19
+
20
+ # Block WordPress paths
21
+ WORDPRESS_PATHS = %w[
22
+ /wp-admin
23
+ /wp-login
24
+ /wp-content
25
+ /wp-includes
26
+ /xmlrpc.php
27
+ /wp-config
28
+ /wp-cron
29
+ ].freeze
30
+
31
+ Rack::Attack.blocklist("block-wordpress") do |req|
32
+ WORDPRESS_PATHS.any? { |path| req.path.downcase.start_with?(path) }
33
+ end
34
+
35
+ # Block sensitive file access
36
+ SENSITIVE_PATTERNS = %w[
37
+ /.env
38
+ /.git
39
+ /.htaccess
40
+ /.htpasswd
41
+ /.aws
42
+ /.ssh
43
+ /.svn
44
+ ].freeze
45
+
46
+ Rack::Attack.blocklist("block-sensitive-files") do |req|
47
+ path = req.path.downcase
48
+ SENSITIVE_PATTERNS.any? { |pattern| path.start_with?(pattern) } ||
49
+ path.include?("/etc/passwd") ||
50
+ path.include?("/etc/shadow")
51
+ end
52
+
53
+ # Block common scanner targets
54
+ SCANNER_PATHS = %w[
55
+ /cgi-bin
56
+ /phpmyadmin
57
+ /pma
58
+ /myadmin
59
+ /phpinfo
60
+ /mysqladmin
61
+ /admin/config.php
62
+ /solr/
63
+ /actuator
64
+ /telescope/requests
65
+ /debug
66
+ /_ignition
67
+ /vendor/phpunit
68
+ ].freeze
69
+
70
+ Rack::Attack.blocklist("block-scanner-targets") do |req|
71
+ SCANNER_PATHS.any? { |path| req.path.downcase.start_with?(path) }
72
+ end
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Throttles
76
+ # ---------------------------------------------------------------------------
77
+
78
+ # General rate limit: 300 requests per 5 minutes per IP
79
+ Rack::Attack.throttle("req/ip", limit: 300, period: 5.minutes) do |req|
80
+ req.ip unless req.path.start_with?("/assets")
81
+ end
82
+
83
+ # Login throttle: 5 attempts per 20 seconds per IP
84
+ Rack::Attack.throttle("login/ip", limit: 5, period: 20.seconds) do |req|
85
+ req.ip if req.path == "/session" && req.post?
86
+ end
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Custom responses
90
+ # ---------------------------------------------------------------------------
91
+
92
+ # Return 403 Forbidden for blocklisted requests
93
+ Rack::Attack.blocklisted_responder = lambda do |_request|
94
+ [403, {"content-type" => "text/plain"}, ["Forbidden\n"]]
95
+ end
@@ -11,6 +11,8 @@ module Maquina
11
11
  desc: "Environment variable for HTTP auth username"
12
12
  class_option :password_env_var, type: :string, default: "SOLID_ERRORS_PASSWORD",
13
13
  desc: "Environment variable for HTTP auth password"
14
+ class_option :copy_views, type: :boolean, default: false,
15
+ desc: "Copy custom Solid Errors views to the host app"
14
16
 
15
17
  # 1. BackstageController
16
18
  def create_backstage_controller
@@ -44,21 +46,30 @@ module Maquina
44
46
  route "mount SolidErrors::Engine, at: \"#{mount_path}\""
45
47
  end
46
48
 
47
- # 5. Bundle install
49
+ # 5. Custom views
50
+ def copy_views
51
+ return unless options[:copy_views]
52
+
53
+ view_files.each do |view|
54
+ copy_file view, view
55
+ end
56
+ end
57
+
58
+ # 6. Bundle install
48
59
  def run_bundle_install
49
60
  return unless rails_app?
50
61
 
51
62
  run "bundle install", capture: true
52
63
  end
53
64
 
54
- # 6. Run solid_errors:install
65
+ # 7. Run solid_errors:install
55
66
  def run_gem_install
56
67
  return unless rails_app?
57
68
 
58
69
  run "bin/rails generate solid_errors:install", capture: true
59
70
  end
60
71
 
61
- # 7. Post-install message
72
+ # 8. Post-install message
62
73
  def show_post_install
63
74
  say ""
64
75
  say "Solid Errors has been installed!", :green
@@ -80,6 +91,13 @@ module Maquina
80
91
  def rails_app?
81
92
  File.exist?(File.join(destination_root, "bin/rails"))
82
93
  end
94
+
95
+ def view_files
96
+ views_dir = File.join(self.class.source_root, "app/views/solid_errors")
97
+ Dir.glob("**/*.erb", base: views_dir).map do |file|
98
+ File.join("app/views/solid_errors", file)
99
+ end
100
+ end
83
101
  end
84
102
  end
85
103
  end
@@ -0,0 +1,39 @@
1
+ <%# locals: (error:) %>
2
+
3
+ <%= render "components/card" do %>
4
+ <div class="p-6 pb-2">
5
+ <h3 class="text-lg font-medium">Actions</h3>
6
+ </div>
7
+
8
+ <div class="p-6 pt-0 space-y-3">
9
+ <%= link_to solid_errors.root_path, class: "button w-full justify-center" do %>
10
+ <svg
11
+ xmlns="http://www.w3.org/2000/svg"
12
+ width="16"
13
+ height="16"
14
+ viewBox="0 0 24 24"
15
+ fill="none"
16
+ stroke="currentColor"
17
+ stroke-width="2"
18
+ stroke-linecap="round"
19
+ stroke-linejoin="round"
20
+ class="lucide"
21
+ >
22
+ <path d="m12 19-7-7 7-7" />
23
+ <path d="M19 12H5" />
24
+ </svg>
25
+
26
+ <span>Back to Errors</span>
27
+ <% end %>
28
+
29
+ <% if error.resolved? %>
30
+ <%= render "solid_errors/errors/delete_button",
31
+ error: error,
32
+ class: "w-full justify-center" %>
33
+ <% else %>
34
+ <%= render "solid_errors/errors/resolve_button",
35
+ error: error,
36
+ class: "w-full justify-center" %>
37
+ <% end %>
38
+ </div>
39
+ <% end %>
@@ -0,0 +1,24 @@
1
+ <%# locals: (error:) %>
2
+
3
+ <%= button_to solid_errors.error_path(error),
4
+ method: :delete,
5
+ class: "button button-danger w-full justify-center inline-flex items-center gap-2 py-2 px-4 transition-all hover:scale-[1.02] active:scale-95",
6
+ data: { turbo_confirm: "Are you sure you want to delete this error?" } do %>
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ width="16"
10
+ height="16"
11
+ viewBox="0 0 24 24"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ stroke-width="2"
15
+ stroke-linecap="round"
16
+ stroke-linejoin="round"
17
+ class="lucide"
18
+ >
19
+ <path d="M3 6h18" />
20
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
21
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
22
+ </svg>
23
+ <span>Delete Error</span>
24
+ <% end %>
@@ -0,0 +1,106 @@
1
+ <%# locals: (error:) %>
2
+
3
+ <%= render "components/card", css_classes: "hover:shadow-md transition-shadow" do %>
4
+ <div class="p-6">
5
+ <div class="flex items-start justify-between gap-4">
6
+ <div class="flex-1 min-w-0">
7
+ <!-- Severity & Status -->
8
+ <div class="flex items-center gap-2 mb-2">
9
+ <%= render "components/severity_badge", severity: error.severity %>
10
+ <%= render "components/error_status_badge", resolved: error.resolved? %>
11
+ </div>
12
+
13
+ <!-- Error Title -->
14
+ <%= link_to solid_errors.error_path(error),
15
+ class: "text-lg font-semibold text-foreground hover:text-primary transition-colors" do %>
16
+ <code class="break-words"><%= error.exception_class %></code>
17
+ <% end %>
18
+
19
+ <div class="mt-1 text-sm text-muted-foreground">
20
+ from <em><code><%= error.source %></code></em>
21
+ </div>
22
+
23
+ <!-- Message with overflow handling -->
24
+ <pre
25
+ class="
26
+ mt-3 text-sm text-foreground whitespace-pre-wrap font-mono bg-muted/50 p-3
27
+ rounded-md overflow-auto max-h-32 break-words
28
+ "
29
+ ><%= error.message %></pre>
30
+ </div>
31
+
32
+ <!-- Action Button -->
33
+ <div class="flex-shrink-0">
34
+ <% if error.resolved? %>
35
+ <%= button_to solid_errors.error_path(error),
36
+ method: :delete,
37
+ form: { data: { turbo: false } },
38
+ data: { turbo_confirm: "Are you sure you want to delete this error?" },
39
+ class: "button button-danger inline-flex items-center gap-2 py-2 px-4 transition-all hover:scale-[1.02] active:scale-95" do %>
40
+ <svg
41
+ xmlns="http://www.w3.org/2000/svg"
42
+ width="16"
43
+ height="16"
44
+ viewBox="0 0 24 24"
45
+ fill="none"
46
+ stroke="currentColor"
47
+ stroke-width="2"
48
+ stroke-linecap="round"
49
+ stroke-linejoin="round"
50
+ class="lucide"
51
+ >
52
+ <path d="M3 6h18" />
53
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
54
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
55
+ </svg>
56
+
57
+ <span>Delete</span>
58
+ <% end %>
59
+ <% else %>
60
+ <%= button_to solid_errors.error_path(error),
61
+ method: :patch,
62
+ form: { data: { turbo: false } },
63
+ params: { error: { resolved_at: Time.now } },
64
+ class: "button button-primary inline-flex items-center gap-2 py-2 px-4 transition-all hover:scale-[1.02] active:scale-95" do %>
65
+ <svg
66
+ xmlns="http://www.w3.org/2000/svg"
67
+ width="16"
68
+ height="16"
69
+ viewBox="0 0 24 24"
70
+ fill="none"
71
+ stroke="currentColor"
72
+ stroke-width="2"
73
+ stroke-linecap="round"
74
+ stroke-linejoin="round"
75
+ class="lucide"
76
+ >
77
+ <circle cx="12" cy="12" r="10" />
78
+ <path d="m9 12 2 2 4-4" />
79
+ </svg>
80
+
81
+ <span>Resolve</span>
82
+ <% end %>
83
+ <% end %>
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Footer Stats -->
88
+ <div
89
+ class="
90
+ mt-4 flex items-center gap-6 text-sm text-muted-foreground border-t
91
+ border-border pt-4
92
+ "
93
+ >
94
+ <span>
95
+ <strong class="text-foreground"><%= error.occurrences_count %></strong>
96
+ occurrences
97
+ </span>
98
+
99
+ <span>
100
+ Last seen
101
+ <strong class="text-foreground"><%= time_ago_in_words(error.recent_occurrence) %></strong>
102
+ ago
103
+ </span>
104
+ </div>
105
+ </div>
106
+ <% end %>
@@ -0,0 +1,23 @@
1
+ <%# locals: (error:) %>
2
+
3
+ <%= button_to solid_errors.error_path(error),
4
+ method: :patch,
5
+ params: { error: { resolved_at: Time.now } },
6
+ class: "button button-primary w-full justify-center inline-flex items-center gap-2 py-2 px-4 transition-all hover:scale-[1.02] active:scale-95" do %>
7
+ <svg
8
+ xmlns="http://www.w3.org/2000/svg"
9
+ width="16"
10
+ height="16"
11
+ viewBox="0 0 24 24"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ stroke-width="2"
15
+ stroke-linecap="round"
16
+ stroke-linejoin="round"
17
+ class="lucide"
18
+ >
19
+ <circle cx="12" cy="12" r="10" />
20
+ <path d="m9 12 2 2 4-4" />
21
+ </svg>
22
+ <span>Resolve Error</span>
23
+ <% end %>
@@ -0,0 +1,74 @@
1
+ <div class="container mx-auto px-4 py-8">
2
+ <!-- Header with tabs -->
3
+ <div class="mb-6 border-b border-border">
4
+ <div class="flex items-center justify-between mb-4">
5
+ <h1 class="text-2xl font-bold text-foreground">Errors</h1>
6
+
7
+ <!-- Delete All Resolved Button - Only on Resolved tab -->
8
+ <% if error_scope.resolved? %>
9
+ <% resolved_count = SolidErrors::Error.resolved.count %>
10
+ <% if resolved_count > 0 %>
11
+ <%= button_to main_app.solid_errors_resolved_errors_path,
12
+ method: :delete,
13
+ form: { data: { turbo: false } },
14
+ data: {
15
+ turbo_confirm: "This will queue deletion of all #{resolved_count} resolved error#{resolved_count == 1 ? '' : 's'}. The process will run in the background. Are you sure?"
16
+ },
17
+ class: "button button-danger inline-flex items-center gap-2 py-2 px-4 text-sm transition-all hover:scale-[1.02] active:scale-95" do %>
18
+ <svg
19
+ xmlns="http://www.w3.org/2000/svg"
20
+ width="16"
21
+ height="16"
22
+ viewBox="0 0 24 24"
23
+ fill="none"
24
+ stroke="currentColor"
25
+ stroke-width="2"
26
+ stroke-linecap="round"
27
+ stroke-linejoin="round"
28
+ class="lucide"
29
+ >
30
+ <path d="M3 6h18" />
31
+
32
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
33
+
34
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
35
+
36
+ <line
37
+ x1="10"
38
+ x2="10"
39
+ y1="11"
40
+ y2="17"
41
+ />
42
+
43
+ <line
44
+ x1="14"
45
+ x2="14"
46
+ y1="11"
47
+ y2="17"
48
+ />
49
+ </svg>
50
+ <span>Delete All Resolved</span>
51
+ <% end %>
52
+ <% end %>
53
+ <% end %>
54
+ </div>
55
+
56
+ <!-- Tab Navigation -->
57
+ <nav class="-mb-px flex space-x-8" aria-label="Tabs">
58
+ <%= link_to solid_errors.root_path(scope: :unresolved),
59
+ class: "inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium #{ error_scope.unresolved? ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:border-border hover:text-foreground' }" do %>
60
+ <span>⏳ Unresolved</span>
61
+ <% end %>
62
+
63
+ <%= link_to solid_errors.root_path(scope: :resolved),
64
+ class: "inline-flex items-center border-b-2 px-1 py-4 text-sm font-medium #{ error_scope.resolved? ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:border-border hover:text-foreground' }" do %>
65
+ <span>✅ Resolved</span>
66
+ <% end %>
67
+ </nav>
68
+ </div>
69
+
70
+ <!-- Error Grid -->
71
+ <div class="grid grid-cols-1 gap-4">
72
+ <%= render partial: "solid_errors/errors/error_card", collection: @errors, as: :error %>
73
+ </div>
74
+ </div>
@@ -0,0 +1,60 @@
1
+ <%# locals: (error:) %>
2
+
3
+ <%= render "components/card" do %>
4
+ <div class="p-6 pb-2">
5
+ <h3 class="text-lg font-medium text-foreground">Actions</h3>
6
+ </div>
7
+
8
+ <div class="p-6 pt-0 space-y-3">
9
+ <!-- Clean Old Occurrences Button - FIRST (only if more than 10) -->
10
+ <% if error.occurrences.count > 10 %>
11
+ <%= button_to main_app.solid_errors_error_occurrences_path(error),
12
+ method: :delete,
13
+ form: { data: { turbo: false } },
14
+ data: {
15
+ turbo_confirm: "This will delete #{error.occurrences.count - 10} old occurrence#{'s' if error.occurrences.count - 10 != 1}, keeping only the latest 10. Are you sure?"
16
+ },
17
+ class: "button w-full justify-center inline-flex items-center gap-2 py-2 px-4 bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border-yellow-300 transition-all hover:scale-[1.02] active:scale-95" do %>
18
+ <svg
19
+ xmlns="http://www.w3.org/2000/svg"
20
+ width="16"
21
+ height="16"
22
+ viewBox="0 0 24 24"
23
+ fill="none"
24
+ stroke="currentColor"
25
+ stroke-width="2"
26
+ stroke-linecap="round"
27
+ stroke-linejoin="round"
28
+ class="lucide"
29
+ >
30
+ <path d="M3 6h18" />
31
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
32
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
33
+
34
+ <line
35
+ x1="10"
36
+ x2="10"
37
+ y1="11"
38
+ y2="17"
39
+ />
40
+
41
+ <line
42
+ x1="14"
43
+ x2="14"
44
+ y1="11"
45
+ y2="17"
46
+ />
47
+ </svg>
48
+
49
+ <span>Clean Old Occurrences</span>
50
+ <% end %>
51
+ <% end %>
52
+
53
+ <!-- Resolve/Delete Button - SECOND -->
54
+ <% if error.resolved? %>
55
+ <%= render "solid_errors/errors/delete_button", error: error %>
56
+ <% else %>
57
+ <%= render "solid_errors/errors/resolve_button", error: error %>
58
+ <% end %>
59
+ </div>
60
+ <% end %>