perchfall-rails 0.2.2 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a7e457a262b1fbd251ebbcb6f5cae6c73a8b9659fe5b8db4bf81610e5022a16
4
- data.tar.gz: 1e2cdc985b3210d4c60e2beb6d5cfde567cbf88afcd18c13d67b1f7db1189013
3
+ metadata.gz: 43b1de1c9380bf8b7d9b87e2c9e921840da81511ed2432457131780f9a9b2c4b
4
+ data.tar.gz: 89091a050cbc5c9c666fcbfb68840679c1de88c7c76ea9c28b49cd8635ec574f
5
5
  SHA512:
6
- metadata.gz: f756f27804e3f1cf8671ca03f1e08957b431a1498777e7f4be83fde2ec4da151e61d45af17005f9e292ace6486e9c2f4fb85b985c4cc66f2a0ea6b911cdbd6dc
7
- data.tar.gz: 48fafc4e5f77435cd169721e13b318f4c78b790054078dfb6f87bca07cd2bcf1f7e4c62dcda1206649b1fe1a502d7981a4ea698dcb73a1c06bfdadc369369309
6
+ metadata.gz: d6ca9dc520fcd29169b2d47d996432fbcfe9f1c4b1d8ba36c53e265e56c99bfaf53aeff8e8554eb0d261c3637c643f6b8ab170f74eb61914a9b02e3ea0366318
7
+ data.tar.gz: eb3d8ccb62d55e3d853d56d88e00809f9d1fe3e58a979c24221460fa43928943ba4cbd241801f4caadd196802f0d81b21b09901c4dfe740be44548c93417b2a1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-06-13
11
+
12
+ ### Added
13
+
14
+ - **`config.after_persist`** — optional callable invoked once per persisted `Perchfall::Rails::SyntheticRun`. Fires on the happy path *and* `RunJob`'s error rescues; receives the persisted run as a single positional argument. Exceptions raised by the hook are rescued (both `StandardError` and `ScriptError`, so a host `NotImplementedError` can't supplant the engine's own error) and logged with the run id and a partial backtrace — they do not interrupt control flow or prevent ActiveJob retries. See [ADR 0006](docs/adr/0006-synthetic-run-after-persist-hook.md).
15
+ - **`config.synthetic_run_extension`** — optional string naming a host module that the engine constantizes and includes into `Perchfall::Rails::SyntheticRun`. Use this to declare host-side associations, scopes, or callbacks (e.g. `belongs_to :user`) instead of reopening the class from a `config.to_prepare` block. Applied from the engine's own `config.to_prepare`, which fires after host initializers, after eager_load, and again on every code reload (dev). See [ADR 0007](docs/adr/0007-synthetic-run-extension.md).
16
+ - **`synthetic_runs_scope` override hook on `SyntheticRunsController`** — hosts define the method on their `base_controller` to scope the index and show actions (e.g. `where(user: current_user)`). The engine dispatches into it via `respond_to?(:synthetic_runs_scope, true)`, so DSL-materialized methods (method_missing-based delegation) are detected and unrelated same-named methods elsewhere in the ancestry don't get mistaken for the seam. Authorization-by-scoping falls out of the design: `show` 404s for records outside the host's scope without a separate authorization layer. The seam validates the host's return value (raises a self-diagnosing error if nil or non-relation) and normalizes ordering (applies `recent` if the host's relation has no `order_values`), so "always validated, always ordered" applies to every consumer of the seam, not just `index`. See [ADR 0005](docs/adr/0005-synthetic-runs-controller-scope-hook.md).
17
+ - **[ADR 0004](docs/adr/0004-engine-extension-pattern.md)** articulates the engine's host-extension model — "install-and-go defaults with named override seams" — and partially backfills 0.1.0's `base_controller` and 0.3.0's `overrides_stylesheet` under the same principle. Names four established seam shapes and what's explicitly out of bounds (`config.to_prepare` monkey-patches, `Module#prepend` from initializers, wholesale engine class replacement).
18
+ - Install generator template now exposes all five configuration attributes, including the two new ones (`after_persist`, `synthetic_run_extension`). Generator test asserts every attribute appears in the template.
19
+
20
+ ### Breaking changes
21
+
22
+ - `SyntheticRun::Persist.call` renamed to `Persist.from_report`, with a new companion `Persist.from_error` that `RunJob`'s rescues route through. Persistence is now consolidated in one place, which is what makes `after_persist` fire for error-path runs. The class-method API is undocumented and the engine's own `RunJob` is updated in this release, but any host calling `Persist.call` directly will see `NoMethodError` post-upgrade and must update to `Persist.from_report`.
23
+
24
+ ### Changed
25
+
26
+ - `Perchfall::Rails::Configuration` attribute order alphabetized to keep future additions consistent.
27
+
28
+ ## [0.3.0] - 2026-05-16
29
+
30
+ ### Breaking changes
31
+
32
+ - Engine no longer depends on Tailwind CSS or the consumer's design tokens. The dashboard now ships with its own self-contained stylesheet that works on any Rails app. Hosts using the previous Tailwind bridge file (`app/assets/builds/tailwind/perchfall_rails.css`) must remove the `@import` and the `@theme { --color-* }` token block. To customize colors, configure `Perchfall::Rails.configuration.overrides_stylesheet` and define `--perchfall-*` CSS custom properties. See the README "Theming" section for migration steps.
33
+
34
+ ### Added
35
+
36
+ - `Perchfall::Rails::Configuration#overrides_stylesheet` — optional path to a host-provided stylesheet loaded after the engine's CSS, for theming via `--perchfall-*` CSS custom properties.
37
+ - Dark mode via `prefers-color-scheme: dark` — engine stylesheet defines both light and dark token sets.
38
+ - Hand-authored engine stylesheet at `app/assets/stylesheets/perchfall/rails/application.css` served through the host's asset pipeline (Propshaft or Sprockets).
39
+
40
+ ### Changed
41
+
42
+ - Engine layout now loads its own stylesheet (`perchfall/rails/application`) instead of the host's `:app` bundle, isolating dashboard styles from host CSS. Hosts that need full chrome (admin nav, branding) can override the layout by placing their own `app/views/layouts/perchfall/rails/application.html.erb`.
43
+ - All engine views use namespaced `.pf-*` utility classes instead of bare Tailwind utilities.
44
+
45
+ ### Fixed
46
+
47
+ - `SyntheticRunsController` now declares `layout "perchfall/rails/application"` explicitly. Without this, Rails fell back to the host's `layouts/application` and dropped the engine layout entirely — no `<!DOCTYPE>`, no stylesheet link, no `.pf-root` wrapper.
48
+
49
+ ### Removed
50
+
51
+ - `app/assets/tailwind/perchfall_rails/` bridge file. Tailwind is no longer a supported integration path.
52
+ - `docs/tailwind-v3.md`.
53
+
10
54
  ## [0.2.2] - 2026-05-05
11
55
 
12
56
  ### Changed
@@ -51,7 +95,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
51
95
  - Tailwind CSS integration: semantic token names (`primary`, `muted`, `subtle`, `error`, `border-subtle`, `neutral-0/150/900`) in all views. Tailwind v4 hosts import `perchfall_rails/engine` to scan the engine's views automatically; Tailwind v3 hosts add the engine view path to their `content` configuration.
52
96
  - Runtime dependencies: `perchfall ~> 0.4`, `pagy ~> 43.0`, Rails `>= 8.0`.
53
97
 
54
- [Unreleased]: https://github.com/beflagrant/perchfall-rails/compare/v0.2.2...HEAD
98
+ [Unreleased]: https://github.com/beflagrant/perchfall-rails/compare/v0.4.0...HEAD
99
+ [0.4.0]: https://github.com/beflagrant/perchfall-rails/compare/v0.3.0...v0.4.0
100
+ [0.3.0]: https://github.com/beflagrant/perchfall-rails/compare/v0.2.2...v0.3.0
55
101
  [0.2.2]: https://github.com/beflagrant/perchfall-rails/compare/v0.2.1...v0.2.2
56
102
  [0.2.1]: https://github.com/beflagrant/perchfall-rails/compare/v0.2.0...v0.2.1
57
103
  [0.2.0]: https://github.com/beflagrant/perchfall-rails/compare/v0.1.0...v0.2.0
data/README.md CHANGED
@@ -8,14 +8,15 @@ A mountable Rails engine that adds a synthetic monitoring dashboard and backgrou
8
8
 
9
9
  ## Why perchfall-rails
10
10
 
11
- [perchfall](https://github.com/beflagrant/perchfall) gives you a `Report` value object — what a real Chromium browser saw at a URL. It deliberately doesn't impose a database schema or a job queue; you bring your own.
11
+ [perchfall](https://github.com/beflagrant/perchfall) gives you a `Report` value object — what a real Chromium browser saw at a URL. It deliberately does not impose a database schema or job queue; you bring your own.
12
12
 
13
- perchfall-rails is the batteries-included path for Rails apps:
13
+ perchfall-rails ships the Rails pieces:
14
14
 
15
15
  - **Schema and persistence** — a `SyntheticRun` ActiveRecord model that stores every check, with denormalized error counts and the full report JSON for later inspection.
16
16
  - **Background job** — a `Perchfall::Rails::RunJob` ActiveJob subclass you can `perform_later` (including with `ignore:` rules), with sensible retry behavior.
17
17
  - **Dashboard** — paginated, filterable index of runs and a detail view that surfaces every network and console error.
18
18
  - **Auth-agnostic** — point `base_controller` at any controller in your app and inherit its authentication.
19
+ - **Extensible** — host-facing seams let you add associations, scope the dashboard to the current user, and react to each persisted run without monkey-patching the engine. See [ADR 0004](docs/adr/0004-engine-extension-pattern.md).
19
20
 
20
21
  If you only need the Ruby API, use perchfall directly. If you want a Rails-idiomatic place for synthetic results to live, mount this.
21
22
 
@@ -23,119 +24,122 @@ If you only need the Ruby API, use perchfall directly. If you want a Rails-idiom
23
24
 
24
25
  - Ruby >= 3.3
25
26
  - Rails >= 8.0
26
- - Node.js >= 18 (required by perchfall for Playwright)
27
- - [pagy](https://github.com/ddnexus/pagy) ~> 43.0 (bundled as a dependency — no separate install needed)
27
+ - Node.js >= 18 with the `playwright` npm package and a Chromium binary (installed via the engine's rake task — see below)
28
+ - [pagy](https://github.com/ddnexus/pagy) ~> 43.0 (bundled as a dependency)
28
29
 
29
30
  ## Installation
30
31
 
31
- Add to your Gemfile:
32
+ Add to your `Gemfile`:
32
33
 
33
34
  ```ruby
34
35
  gem "perchfall-rails"
35
36
  ```
36
37
 
37
- Then run:
38
+ Install the gem, the initializer, the engine migrations, and Playwright:
38
39
 
39
40
  ```sh
40
41
  bundle install
41
- ```
42
-
43
- ### Node.js and Playwright
44
-
45
- The engine requires Node.js and the Playwright npm package with a Chromium browser binary. After bundling:
46
-
47
- ```sh
42
+ bin/rails generate perchfall:rails:install
43
+ bin/rails perchfall_rails:install:migrations
44
+ bin/rails db:migrate
48
45
  bin/rails perchfall_rails:install:playwright
49
46
  ```
50
47
 
51
- This runs `npm install playwright` and `npx playwright install chromium`. Verify the full environment with:
48
+ Mount the engine in `config/routes.rb`:
52
49
 
53
- ```sh
54
- bin/rails perchfall_rails:check
50
+ ```ruby
51
+ mount Perchfall::Rails::Engine, at: "/perchfall"
55
52
  ```
56
53
 
57
- ### Initializer
58
-
59
- Generate the configuration initializer:
54
+ Verify the Node/Playwright/Chromium environment:
60
55
 
61
56
  ```sh
62
- bin/rails generate perchfall:rails:install
57
+ bin/rails perchfall_rails:check
63
58
  ```
64
59
 
65
- This creates `config/initializers/perchfall_rails.rb` and prints the remaining setup steps.
60
+ The dashboard is now available at `/perchfall/synthetic_runs`.
66
61
 
67
- ### Migrations
62
+ ## Quick start
68
63
 
69
- Install and run the engine migrations:
64
+ Enqueue a synthetic run from anywhere in your application:
70
65
 
71
- ```sh
72
- bin/rails perchfall_rails:install:migrations
73
- bin/rails db:migrate
66
+ ```ruby
67
+ Perchfall::Rails::RunJob.perform_later(url: "https://example.com")
68
+ # => #<Perchfall::Rails::RunJob ...>
74
69
  ```
75
70
 
76
- ### Routes
71
+ When the job runs, it creates one `SyntheticRun` record with the full Chromium report, network errors, and console errors. View it at `/perchfall/synthetic_runs`.
77
72
 
78
- Mount the engine in `config/routes.rb`:
73
+ To suppress known-noisy errors, pass `ignore:` rules:
79
74
 
80
75
  ```ruby
81
- mount Perchfall::Rails::Engine, at: "/perchfall"
82
- ```
76
+ rule = Perchfall::IgnoreRule.new(
77
+ pattern: "chrome-error://",
78
+ type: "*",
79
+ target: :console
80
+ )
83
81
 
84
- The dashboard will be available at `/perchfall/synthetic_runs`.
82
+ Perchfall::Rails::RunJob.perform_later(
83
+ url: "https://example.com",
84
+ scenario_name: "homepage",
85
+ ignore: [rule]
86
+ )
87
+ ```
85
88
 
86
89
  ## Configuration
87
90
 
88
- Create an initializer at `config/initializers/perchfall_rails.rb`:
91
+ The install generator creates `config/initializers/perchfall_rails.rb` with `base_controller` set to `"ApplicationController"` (with a warning logged at boot in production until you change it) and the optional attributes pre-commented with sample values. A configured initializer looks like:
89
92
 
90
93
  ```ruby
91
94
  Perchfall::Rails.configure do |config|
92
- # Required in production: set this to a controller that enforces authentication.
93
- # The engine will log a warning at boot if this is still ApplicationController
94
- # in a production environment.
95
95
  config.base_controller = "AuthenticatedController"
96
96
 
97
- # ActiveJob queue name for synthetic run jobs. Defaults to :synthetic.
98
- config.queue_name = :synthetic
97
+ # config.queue_name = :synthetic
98
+ # config.overrides_stylesheet = "perchfall_rails_overrides"
99
+ # config.after_persist = ->(run) { SomeJob.perform_later(run.id) if run.failed? }
100
+ # config.synthetic_run_extension = "SyntheticRunExtension"
99
101
  end
100
102
  ```
101
103
 
104
+ - `base_controller` — controller the engine inherits from. Required in production; the engine logs a warning at boot if this is still `ApplicationController` in a production environment. Must inherit from `ActionController::Base`.
105
+ - `queue_name` — ActiveJob queue used by `RunJob`. Defaults to `:synthetic`.
106
+ - `overrides_stylesheet` — optional path (without extension) to a host stylesheet loaded after the engine CSS. Use to override `--perchfall-*` CSS custom properties.
107
+ - `after_persist` — optional callable invoked after each `SyntheticRun` is persisted. Receives the run as a single positional argument and fires on both the happy path and `RunJob`'s error rescues — once per persisted run, which under ActiveJob retries means once per attempt. Use `run.id` to route downstream work to that specific persisted run (`SomeJob.perform_later(run.id)`) — each retry attempt persists a distinct id, so `run.id` does **not** dedup across attempts. The engine provides no cross-attempt key today, so handlers that need once-per-failure-event semantics (notifications, ticket-opens) should be designed to be idempotent on the host's natural keys, or should accept at-least-once delivery. Exceptions raised by the callable are rescued and logged; they do not interrupt the pipeline. See [ADR 0006](docs/adr/0006-synthetic-run-after-persist-hook.md).
108
+ - `synthetic_run_extension` — optional name of a host module the engine includes into `Perchfall::Rails::SyntheticRun` from a `config.to_prepare` block (so the include fires after host initializers, after eager_load in production, and again after every code reload in development). Use it to declare host-side associations, scopes, or callbacks (`belongs_to :user`, &c.). The named module must be autoloadable. Callbacks added through the extension run as part of the engine's `SyntheticRun.create!`; if one raises, the create fails, the job dies, no `SyntheticRun` is written (not even an error row), and `after_persist` does not fire. Reserve extension callbacks for declarations and logic that has been hardened against raising; for observability-style side effects, use `after_persist`. See [ADR 0007](docs/adr/0007-synthetic-run-extension.md).
109
+
102
110
  ## Authentication
103
111
 
104
112
  The engine has no built-in authentication. Set `base_controller` to a controller that enforces whatever authentication your application uses:
105
113
 
106
114
  ```ruby
107
- # config/initializers/perchfall_rails.rb
108
115
  Perchfall::Rails.configure do |config|
109
116
  config.base_controller = "Admin::BaseController"
110
117
  end
111
118
  ```
112
119
 
113
- `base_controller` must inherit from `ActionController::Base`. The engine views use `csrf_meta_tags`, `csp_meta_tag`, and `stylesheet_link_tag`, none of which are available on `ActionController::API` controllers.
120
+ `base_controller` must inherit from `ActionController::Base`. Engine views use `csrf_meta_tags`, `csp_meta_tag`, and `stylesheet_link_tag`, none of which are available on `ActionController::API` controllers.
114
121
 
115
- ## Enqueueing checks
122
+ ## Scoping runs to the current user
116
123
 
117
- Enqueue a synthetic run from anywhere in your application:
124
+ By default the dashboard shows every `SyntheticRun` in the database. To scope it — e.g. each user only sees their own runs — define `synthetic_runs_scope` on `base_controller`:
118
125
 
119
126
  ```ruby
120
- Perchfall::Rails::RunJob.perform_later(url: "https://example.com")
121
- ```
127
+ class AuthenticatedController < ApplicationController
128
+ before_action :authenticate_user!
122
129
 
123
- To suppress known-noisy errors, pass `ignore:` rules:
130
+ private
124
131
 
125
- ```ruby
126
- rule = Perchfall::IgnoreRule.new(
127
- pattern: "chrome-error://",
128
- type: "*",
129
- target: :console
130
- )
131
- Perchfall::Rails::RunJob.perform_later(
132
- url: "https://example.com",
133
- scenario_name: "homepage",
134
- ignore: [rule]
135
- )
132
+ def synthetic_runs_scope
133
+ current_user.synthetic_runs
134
+ end
135
+ end
136
136
  ```
137
137
 
138
- **Retry behavior:** When a run fails with a `Perchfall::Errors::Error`, the job persists an error record and then re-raises the exception so the queue adapter can retry normally. Each retry attempt that also fails will create an additional error record. If you want to cap error records at one per enqueued job, configure `discard_on` in a subclass or use your queue adapter's retry settings:
138
+ The engine calls `synthetic_runs_scope` from both `index` and `show`. If the host returns an unordered relation, the engine merges in a default `recent` order so pagination stays stable. Pair this with two association declarations on the host side: a `belongs_to :user` on `Perchfall::Rails::SyntheticRun` declared via `config.synthetic_run_extension` (which includes the host module into the engine's model see [ADR 0007](docs/adr/0007-synthetic-run-extension.md)), and the matching `has_many :synthetic_runs, class_name: "Perchfall::Rails::SyntheticRun"` directly on your host model (e.g. `User`). The `synthetic_run_extension` seam includes its module into `SyntheticRun`, so it can only carry the `belongs_to` side; `has_many` belongs on whichever host model owns the association. See [ADR 0005](docs/adr/0005-synthetic-runs-controller-scope-hook.md).
139
+
140
+ ## Retry behavior
141
+
142
+ When a run fails with `Perchfall::Errors::Error`, the job persists an error record and re-raises so the queue adapter can retry normally. Each retry that also fails creates another error record. To cap error records at one per enqueued job, subclass `RunJob` and `discard_on`:
139
143
 
140
144
  ```ruby
141
145
  class MyRunJob < Perchfall::Rails::RunJob
@@ -143,73 +147,32 @@ class MyRunJob < Perchfall::Rails::RunJob
143
147
  end
144
148
  ```
145
149
 
146
- ## Tailwind CSS
150
+ ## Theming
147
151
 
148
- The engine views use semantic Tailwind token names. Add the following to your Tailwind configuration to define the required tokens:
152
+ The engine ships its own stylesheet. Default colors render well in both light and dark mode (the dashboard follows `prefers-color-scheme`).
149
153
 
150
- **Tailwind v4**:
154
+ To customize colors, point `config.overrides_stylesheet` at a host CSS file:
151
155
 
152
- `tailwindcss-rails` automatically generates a bridge file at `app/assets/builds/tailwind/perchfall_rails.css` when the engine is bundled. Import it from your Tailwind CSS entry point, then define the required tokens. Replace the example values with your application's actual colors:
156
+ ```ruby
157
+ Perchfall::Rails.configure do |config|
158
+ config.overrides_stylesheet = "perchfall_rails_overrides"
159
+ end
160
+ ```
153
161
 
154
162
  ```css
155
- @import "tailwindcss";
156
- @import "../builds/tailwind/perchfall_rails";
157
-
158
- @theme {
159
- --color-primary: #2563eb;
160
- --color-muted: #6b7280;
161
- --color-subtle: #9ca3af;
162
- --color-error: #ef4444;
163
- --color-border-subtle: #e5e7eb;
164
-
165
- --color-neutral-0: #ffffff;
166
- --color-neutral-150: #e5e7eb;
167
- --color-neutral-900: #111827;
163
+ /* app/assets/stylesheets/perchfall_rails_overrides.css */
164
+ :root {
165
+ --perchfall-color-primary: #db2777;
166
+ --perchfall-color-error: #b91c1c;
168
167
  }
169
168
  ```
170
169
 
171
- The `../builds/tailwind/perchfall_rails` path assumes your CSS entry point is in `app/assets/tailwind/` or `app/assets/stylesheets/`. Adjust if your layout differs.
172
-
173
- **Tailwind v3** (`tailwind.config.js`):
174
-
175
- Add the engine's views to your content paths and define the required tokens. Replace the example values with your application's actual colors:
176
-
177
- ```js
178
- const { execSync } = require("child_process");
179
- const enginePath = execSync("bundle show perchfall-rails").toString().trim();
180
-
181
- module.exports = {
182
- content: [
183
- // ... your existing content paths
184
- `${enginePath}/app/views/**/*.html.erb`,
185
- ],
186
- theme: {
187
- extend: {
188
- colors: {
189
- primary: "#2563eb",
190
- muted: "#6b7280",
191
- subtle: "#9ca3af",
192
- error: "#ef4444",
193
- neutral: {
194
- 0: "#ffffff",
195
- 150: "#e5e7eb",
196
- 900: "#111827",
197
- },
198
- },
199
- borderColor: {
200
- subtle: "#e5e7eb",
201
- },
202
- },
203
- },
204
- }
205
- ```
170
+ Available tokens are defined in [`app/assets/stylesheets/perchfall/rails/application.css`](app/assets/stylesheets/perchfall/rails/application.css). Headline tokens: `--perchfall-color-primary`, `--perchfall-color-error`, `--perchfall-color-text`, `--perchfall-color-muted`, `--perchfall-color-subtle`, `--perchfall-color-surface`, `--perchfall-color-surface-alt`, `--perchfall-color-border`. The file also exposes typography, spacing, and radius tokens.
206
171
 
207
- If your application does not use Tailwind, the views render unstyled but functional.
172
+ Hosts that need full chrome around the dashboard (admin nav, branding) can override the engine layout by placing their own `app/views/layouts/perchfall/rails/application.html.erb` in the host application.
208
173
 
209
174
  ## Development
210
175
 
211
- After checking out the repo, run `bin/setup` to install dependencies. Then run `rake test` to run the tests.
212
-
213
176
  ```sh
214
177
  bin/setup
215
178
  bundle exec rake test
@@ -217,12 +180,12 @@ bundle exec rake test
217
180
 
218
181
  ## Contributing
219
182
 
220
- Bug reports and pull requests are welcome on GitHub at https://github.com/beflagrant/perchfall-rails. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/beflagrant/perchfall-rails/blob/main/CODE_OF_CONDUCT.md).
183
+ Bug reports and pull requests are welcome on GitHub at <https://github.com/beflagrant/perchfall-rails>. See [CONTRIBUTING.md](CONTRIBUTING.md). Release notes live in [CHANGELOG.md](CHANGELOG.md).
221
184
 
222
185
  ## License
223
186
 
224
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
187
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
225
188
 
226
189
  ## Code of Conduct
227
190
 
228
- Everyone interacting in the perchfall-rails project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/beflagrant/perchfall-rails/blob/main/CODE_OF_CONDUCT.md).
191
+ Everyone interacting in the perchfall-rails project's codebases, issue trackers, chat rooms, and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
@@ -0,0 +1,258 @@
1
+ /* ===================================================================
2
+ perchfall-rails engine styles
3
+ Hand-authored. Override colors via --perchfall-* CSS custom
4
+ properties in a host stylesheet referenced through
5
+ `config.overrides_stylesheet`.
6
+ =================================================================== */
7
+
8
+ /* -------------------------------------------------------------------
9
+ 1. Tokens (light)
10
+ ------------------------------------------------------------------- */
11
+ :root {
12
+ --perchfall-color-primary: #2563eb;
13
+ --perchfall-color-error: #ef4444;
14
+ --perchfall-color-text: #111827;
15
+ --perchfall-color-muted: #6b7280;
16
+ --perchfall-color-subtle: #9ca3af;
17
+ --perchfall-color-surface: #ffffff;
18
+ --perchfall-color-surface-alt: #f9fafb;
19
+ --perchfall-color-border: #e5e7eb;
20
+
21
+ --perchfall-font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
22
+ --perchfall-font-size-sm: 0.875rem;
23
+ --perchfall-font-size-base: 1rem;
24
+ --perchfall-font-size-lg: 1.125rem;
25
+ --perchfall-font-size-xl: 1.25rem;
26
+ --perchfall-font-size-2xl: 1.5rem;
27
+ --perchfall-font-size-3xl: 1.875rem;
28
+ --perchfall-font-size-5xl: 3rem;
29
+ --perchfall-line-height-tight: 1.2;
30
+ --perchfall-line-height-normal: 1.4;
31
+
32
+ --perchfall-space-0-5: 0.125rem;
33
+ --perchfall-space-1: 0.25rem;
34
+ --perchfall-space-2: 0.5rem;
35
+ --perchfall-space-3: 0.75rem;
36
+ --perchfall-space-4: 1rem;
37
+ --perchfall-space-6: 1.5rem;
38
+ --perchfall-space-8: 2rem;
39
+
40
+ --perchfall-radius-lg: 0.5rem;
41
+ --perchfall-radius-2xl: 1rem;
42
+
43
+ --perchfall-container-max: 1280px;
44
+ }
45
+
46
+ /* -------------------------------------------------------------------
47
+ 2. Tokens (dark)
48
+ ------------------------------------------------------------------- */
49
+ @media (prefers-color-scheme: dark) {
50
+ :root {
51
+ --perchfall-color-primary: #60a5fa;
52
+ --perchfall-color-error: #f87171;
53
+ --perchfall-color-text: #f3f4f6;
54
+ --perchfall-color-muted: #9ca3af;
55
+ --perchfall-color-subtle: #6b7280;
56
+ --perchfall-color-surface: #111827;
57
+ --perchfall-color-surface-alt: #1f2937;
58
+ --perchfall-color-border: #374151;
59
+ }
60
+ }
61
+
62
+ /* -------------------------------------------------------------------
63
+ 3. Reset (scoped to .pf-root)
64
+ ------------------------------------------------------------------- */
65
+ .pf-root,
66
+ .pf-root *,
67
+ .pf-root *::before,
68
+ .pf-root *::after {
69
+ box-sizing: border-box;
70
+ }
71
+
72
+ .pf-root {
73
+ font-family: var(--perchfall-font-family);
74
+ color: var(--perchfall-color-text);
75
+ line-height: var(--perchfall-line-height-normal);
76
+ background: var(--perchfall-color-surface-alt);
77
+ min-height: 100vh;
78
+ }
79
+
80
+ .pf-root a {
81
+ color: inherit;
82
+ text-decoration: none;
83
+ }
84
+
85
+ .pf-root hr {
86
+ border: none;
87
+ border-top: 1px solid var(--perchfall-color-border);
88
+ margin: 0;
89
+ }
90
+
91
+ .pf-container {
92
+ max-width: var(--perchfall-container-max);
93
+ margin: 0 auto;
94
+ padding: var(--perchfall-space-8);
95
+ }
96
+
97
+ /* -------------------------------------------------------------------
98
+ 4. Utilities (.pf-*)
99
+ ------------------------------------------------------------------- */
100
+
101
+ /* Display */
102
+ .pf-flex { display: flex; }
103
+ .pf-grid { display: grid; }
104
+
105
+ /* Flex */
106
+ .pf-flex-col { flex-direction: column; }
107
+ .pf-flex-row { flex-direction: row; }
108
+ .pf-flex-1 { flex: 1 1 0%; }
109
+ .pf-items-start { align-items: flex-start; }
110
+ .pf-items-center { align-items: center; }
111
+ .pf-justify-between { justify-content: space-between; }
112
+ .pf-self-stretch { align-self: stretch; }
113
+
114
+ /* Grid templates */
115
+ .pf-grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
116
+ .pf-grid-runs { grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr; }
117
+ .pf-grid-summary { grid-template-columns: 1fr 1fr; }
118
+
119
+ /* Gap */
120
+ .pf-gap-0-5 { gap: var(--perchfall-space-0-5); }
121
+ .pf-gap-1 { gap: var(--perchfall-space-1); }
122
+ .pf-gap-2 { gap: var(--perchfall-space-2); }
123
+ .pf-gap-3 { gap: var(--perchfall-space-3); }
124
+ .pf-gap-4 { gap: var(--perchfall-space-4); }
125
+ .pf-gap-6 { gap: var(--perchfall-space-6); }
126
+
127
+ /* Sizing */
128
+ .pf-w-full { width: 100%; }
129
+ .pf-w-64 { width: 16rem; }
130
+
131
+ /* Padding */
132
+ .pf-p-8 { padding: var(--perchfall-space-8); }
133
+ .pf-px-3 { padding-left: var(--perchfall-space-3); padding-right: var(--perchfall-space-3); }
134
+ .pf-px-4 { padding-left: var(--perchfall-space-4); padding-right: var(--perchfall-space-4); }
135
+ .pf-py-2 { padding-top: var(--perchfall-space-2); padding-bottom: var(--perchfall-space-2); }
136
+
137
+ /* Typography: size */
138
+ .pf-text-sm { font-size: var(--perchfall-font-size-sm); }
139
+ .pf-text-base { font-size: var(--perchfall-font-size-base); }
140
+ .pf-text-lg { font-size: var(--perchfall-font-size-lg); }
141
+ .pf-text-xl { font-size: var(--perchfall-font-size-xl); }
142
+ .pf-text-2xl { font-size: var(--perchfall-font-size-2xl); }
143
+ .pf-text-3xl { font-size: var(--perchfall-font-size-3xl); }
144
+ .pf-text-5xl { font-size: var(--perchfall-font-size-5xl); }
145
+
146
+ /* Typography: weight */
147
+ .pf-font-normal { font-weight: 400; }
148
+ .pf-font-semibold { font-weight: 600; }
149
+ .pf-font-extrabold { font-weight: 800; }
150
+
151
+ /* Typography: line-height */
152
+ .pf-leading-tight { line-height: var(--perchfall-line-height-tight); }
153
+ .pf-leading-normal { line-height: var(--perchfall-line-height-normal); }
154
+
155
+ /* Typography: color */
156
+ .pf-text-primary { color: var(--perchfall-color-primary); }
157
+ .pf-text-error { color: var(--perchfall-color-error); }
158
+ .pf-text-muted { color: var(--perchfall-color-muted); }
159
+ .pf-text-subtle { color: var(--perchfall-color-subtle); }
160
+ .pf-text-body { color: var(--perchfall-color-text); }
161
+
162
+ /* Typography: alignment + decoration */
163
+ .pf-text-right { text-align: right; }
164
+ .pf-capitalize { text-transform: capitalize; }
165
+ .pf-underline { text-decoration: underline; }
166
+ .pf-truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
167
+ .pf-break-all { word-break: break-all; }
168
+
169
+ /* Borders */
170
+ .pf-border { border: 1px solid var(--perchfall-color-border); }
171
+ .pf-border-subtle { border-color: var(--perchfall-color-border); }
172
+ .pf-rounded-lg { border-radius: var(--perchfall-radius-lg); }
173
+ .pf-rounded-2xl { border-radius: var(--perchfall-radius-2xl); }
174
+
175
+ /* Backgrounds */
176
+ .pf-bg-surface { background-color: var(--perchfall-color-surface); }
177
+ .pf-bg-surface-alt { background-color: var(--perchfall-color-surface-alt); }
178
+
179
+ /* Effects */
180
+ .pf-cursor-pointer { cursor: pointer; }
181
+ .pf-opacity-60 { opacity: 0.6; }
182
+
183
+ /* Forms */
184
+ .pf-input,
185
+ .pf-select {
186
+ font: inherit;
187
+ color: var(--perchfall-color-text);
188
+ background-color: var(--perchfall-color-surface);
189
+ border: 1px solid var(--perchfall-color-border);
190
+ border-radius: var(--perchfall-radius-lg);
191
+ padding: var(--perchfall-space-2) var(--perchfall-space-3);
192
+ }
193
+ .pf-input:focus,
194
+ .pf-select:focus {
195
+ outline: 2px solid var(--perchfall-color-primary);
196
+ outline-offset: 1px;
197
+ }
198
+ .pf-button {
199
+ font: inherit;
200
+ color: var(--perchfall-color-primary);
201
+ background-color: var(--perchfall-color-surface);
202
+ border: 1px solid var(--perchfall-color-border);
203
+ border-radius: var(--perchfall-radius-lg);
204
+ padding: var(--perchfall-space-2) var(--perchfall-space-4);
205
+ cursor: pointer;
206
+ font-weight: 600;
207
+ }
208
+ .pf-button:hover {
209
+ background-color: var(--perchfall-color-surface-alt);
210
+ }
211
+
212
+ /* Hover utilities */
213
+ .pf-hover\:pf-text-primary:hover { color: var(--perchfall-color-primary); }
214
+
215
+ /* Responsive: md (>= 768px) */
216
+ @media (min-width: 768px) {
217
+ .pf-md\:pf-flex-row { flex-direction: row; }
218
+ .pf-md\:pf-items-center { align-items: center; }
219
+ }
220
+
221
+ /* -------------------------------------------------------------------
222
+ 5. Pagy pagination
223
+ ------------------------------------------------------------------- */
224
+ .pf-root nav.pagy {
225
+ display: flex;
226
+ gap: var(--perchfall-space-2);
227
+ align-items: center;
228
+ font-size: var(--perchfall-font-size-sm);
229
+ margin-top: var(--perchfall-space-4);
230
+ }
231
+
232
+ .pf-root nav.pagy a,
233
+ .pf-root nav.pagy span,
234
+ .pf-root nav.pagy label {
235
+ display: inline-block;
236
+ padding: var(--perchfall-space-2) var(--perchfall-space-3);
237
+ border: 1px solid var(--perchfall-color-border);
238
+ border-radius: var(--perchfall-radius-lg);
239
+ color: var(--perchfall-color-primary);
240
+ background-color: var(--perchfall-color-surface);
241
+ text-decoration: none;
242
+ }
243
+
244
+ .pf-root nav.pagy a.current,
245
+ .pf-root nav.pagy .current {
246
+ color: var(--perchfall-color-text);
247
+ background-color: var(--perchfall-color-surface-alt);
248
+ font-weight: 600;
249
+ }
250
+
251
+ .pf-root nav.pagy a:hover {
252
+ background-color: var(--perchfall-color-surface-alt);
253
+ }
254
+
255
+ .pf-root nav.pagy .gap {
256
+ border: none;
257
+ background: transparent;
258
+ }
@@ -3,20 +3,64 @@
3
3
  module Perchfall
4
4
  module Rails
5
5
  class SyntheticRunsController < Perchfall::Rails.configuration.base_controller.constantize
6
+ layout "perchfall/rails/application"
7
+
6
8
  include ::Pagy::Method
7
9
 
8
10
  def index
9
- runs = SyntheticRun.recent
11
+ runs = resolved_synthetic_runs_scope
10
12
  runs = runs.for_status(params[:status]) if SyntheticRun::STATUSES.include?(params[:status])
11
13
  runs = runs.for_url(params[:url]) if params[:url].present?
12
14
  @pagy, @runs = pagy(:offset, runs)
13
15
  end
14
16
 
15
17
  def show
16
- @run = SyntheticRun.find(params[:id])
18
+ @run = resolved_synthetic_runs_scope.find(params[:id])
17
19
  rescue ActiveRecord::RecordNotFound
18
20
  head :not_found
19
21
  end
22
+
23
+ private
24
+
25
+ # ADR 0005's host seam. Hosts define `synthetic_runs_scope` on their
26
+ # `base_controller` to scope the dashboard (e.g. `current_user.synthetic_runs`).
27
+ # This method is the engine's internal dispatcher into that host method
28
+ # — named distinctly from the host hook so:
29
+ #
30
+ # - `respond_to?(:synthetic_runs_scope, true)` consults the host's
31
+ # `respond_to_missing?`, so `method_missing`-based delegation is
32
+ # detected. The previous `defined?(super)` form silently fell through
33
+ # to the engine's unscoped default for that case — an auth-bypass risk.
34
+ # - The host's `synthetic_runs_scope` is reached via normal instance
35
+ # dispatch — no `super` inversion. The previous shape had the engine
36
+ # define a same-named method that called `super` to reach the host's,
37
+ # which is non-obvious to a reader of either side.
38
+ #
39
+ # `respond_to?` still walks the ancestry, so an unrelated method named
40
+ # `synthetic_runs_scope` from a concern or mixin can be picked up —
41
+ # the validation guard below surfaces that as a self-diagnosing error
42
+ # when the unrelated method returns the wrong type.
43
+ #
44
+ # The host's return value is validated; nil or non-relation raises a
45
+ # self-diagnosing error. If the host's relation has no order clause,
46
+ # `recent` is applied — "always ordered" is the seam's contract, not
47
+ # something only `index` patches up.
48
+ def resolved_synthetic_runs_scope
49
+ scope =
50
+ if respond_to?(:synthetic_runs_scope, true)
51
+ synthetic_runs_scope
52
+ else
53
+ Perchfall::Rails::SyntheticRun.recent
54
+ end
55
+
56
+ unless scope.is_a?(ActiveRecord::Relation)
57
+ raise "synthetic_runs_scope must return an ActiveRecord::Relation " \
58
+ "(got #{scope.class}) — see ADR 0005"
59
+ end
60
+ return scope if scope.order_values.any?
61
+
62
+ scope.recent
63
+ end
20
64
  end
21
65
  end
22
66
  end
@@ -10,22 +10,12 @@ module Perchfall
10
10
  def perform(url:, scenario_name: nil, ignore: [], client: nil)
11
11
  client ||= Perchfall::Client.new
12
12
  report = client.run(url: url, scenario_name: scenario_name, ignore: ignore)
13
- SyntheticRun::Persist.call(report, url: url, scenario_name: scenario_name)
13
+ SyntheticRun::Persist.from_report(report, url: url, scenario_name: scenario_name)
14
14
  rescue Perchfall::Errors::PageLoadError => e
15
- SyntheticRun.create!(
16
- url: url,
17
- scenario_name: scenario_name,
18
- status: "error",
19
- raw_report: e.report.to_h
20
- )
15
+ SyntheticRun::Persist.from_error(url: url, scenario_name: scenario_name, raw_report: e.report.to_h)
21
16
  raise
22
17
  rescue Perchfall::Errors::Error => e
23
- SyntheticRun.create!(
24
- url: url,
25
- scenario_name: scenario_name,
26
- status: "error",
27
- raw_report: { "error" => e.message }
28
- )
18
+ SyntheticRun::Persist.from_error(url: url, scenario_name: scenario_name, raw_report: { "error" => e.message })
29
19
  raise
30
20
  end
31
21
  end
@@ -4,10 +4,46 @@ module Perchfall
4
4
  module Rails
5
5
  class SyntheticRun
6
6
  class Persist
7
- def self.call(report, url:, scenario_name: nil)
8
- new(report, url: url, scenario_name: scenario_name).call
7
+ def self.from_report(report, url:, scenario_name: nil)
8
+ persisting { new(report, url: url, scenario_name: scenario_name).call }
9
9
  end
10
10
 
11
+ def self.from_error(url:, raw_report:, scenario_name: nil)
12
+ persisting do
13
+ SyntheticRun.create!(
14
+ url: url, scenario_name: scenario_name, status: "error", raw_report: raw_report
15
+ )
16
+ end
17
+ end
18
+
19
+ # Every entry point that persists a SyntheticRun routes through here so
20
+ # the after_persist hook fires exactly once per persisted run — adding
21
+ # a third entry point can't silently forget the hook, only by-passing
22
+ # this helper can.
23
+ def self.persisting
24
+ run = yield
25
+ invoke_after_persist(run)
26
+ run
27
+ end
28
+ private_class_method :persisting
29
+
30
+ def self.invoke_after_persist(run)
31
+ hook = Perchfall::Rails.configuration.after_persist
32
+ return unless hook
33
+
34
+ hook.call(run)
35
+ rescue StandardError, ScriptError => e
36
+ # Catch ScriptError too — NotImplementedError or LoadError from a host
37
+ # hook would otherwise escape past RunJob's Perchfall-only rescues and
38
+ # supplant the engine's error. Signal/memory exceptions propagate.
39
+ backtrace = (e.backtrace || []).first(5).join("\n ")
40
+ ::Rails.logger.error(
41
+ "Perchfall::Rails after_persist hook raised for SyntheticRun ##{run.id}: " \
42
+ "#{e.class}: #{e.message}\n #{backtrace}"
43
+ )
44
+ end
45
+ private_class_method :invoke_after_persist
46
+
11
47
  def initialize(report, url:, scenario_name:)
12
48
  @report = report
13
49
  @url = url
@@ -5,9 +5,16 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <%= csrf_meta_tags %>
7
7
  <%= csp_meta_tag %>
8
- <%= stylesheet_link_tag :app %>
8
+ <%= stylesheet_link_tag "perchfall/rails/application", media: "all" %>
9
+ <% if Perchfall::Rails.configuration.overrides_stylesheet.present? %>
10
+ <%= stylesheet_link_tag Perchfall::Rails.configuration.overrides_stylesheet, media: "all" %>
11
+ <% end %>
9
12
  </head>
10
13
  <body>
11
- <%= yield %>
14
+ <div class="pf-root">
15
+ <div class="pf-container">
16
+ <%= yield %>
17
+ </div>
18
+ </div>
12
19
  </body>
13
20
  </html>
@@ -1,14 +1,14 @@
1
- <div class="p-8 border border-solid border-neutral-150 rounded-2xl bg-neutral-0 flex flex-col gap-4">
2
- <h3 class="text-3xl font-extrabold leading-[120%] <%= heading_color %>">
3
- <%= title %> <span class="text-2xl font-semibold <%= count_color %>">(<%= count %>)</span>
1
+ <div class="pf-p-8 pf-border pf-border-subtle pf-rounded-2xl pf-bg-surface pf-flex pf-flex-col pf-gap-4">
2
+ <h3 class="pf-text-3xl pf-font-extrabold pf-leading-tight <%= heading_color %>">
3
+ <%= title %> <span class="pf-text-2xl pf-font-semibold <%= count_color %>">(<%= count %>)</span>
4
4
  </h3>
5
5
  <% errors.each_with_index do |err, i| %>
6
- <div class="flex flex-col gap-1 <%= 'opacity-60' if dimmed %>">
7
- <span class="text-xl font-normal text-neutral-900 break-all"><%= err["text"] %></span>
8
- <span class="text-sm font-normal text-muted"><%= err["type"] %> &mdash; <%= err["location"] %></span>
6
+ <div class="pf-flex pf-flex-col pf-gap-1 <%= 'pf-opacity-60' if dimmed %>">
7
+ <span class="pf-text-xl pf-font-normal pf-text-body pf-break-all"><%= err["text"] %></span>
8
+ <span class="pf-text-sm pf-font-normal pf-text-muted"><%= err["type"] %> &mdash; <%= err["location"] %></span>
9
9
  </div>
10
10
  <% unless i == errors.size - 1 %>
11
- <hr class="border-subtle">
11
+ <hr>
12
12
  <% end %>
13
13
  <% end %>
14
14
  </div>
@@ -1,14 +1,14 @@
1
- <div class="p-8 border border-solid border-neutral-150 rounded-2xl bg-neutral-0 flex flex-col gap-4">
2
- <h3 class="text-3xl font-extrabold leading-[120%] <%= heading_color %>">
3
- <%= title %> <span class="text-2xl font-semibold <%= count_color %>">(<%= count %>)</span>
1
+ <div class="pf-p-8 pf-border pf-border-subtle pf-rounded-2xl pf-bg-surface pf-flex pf-flex-col pf-gap-4">
2
+ <h3 class="pf-text-3xl pf-font-extrabold pf-leading-tight <%= heading_color %>">
3
+ <%= title %> <span class="pf-text-2xl pf-font-semibold <%= count_color %>">(<%= count %>)</span>
4
4
  </h3>
5
5
  <% errors.each_with_index do |err, i| %>
6
- <div class="flex flex-col gap-1 <%= 'opacity-60' if dimmed %>">
7
- <span class="text-xl font-normal text-neutral-900 break-all"><%= err["url"] %></span>
8
- <span class="text-sm font-normal text-muted"><%= err["method"] %> &mdash; <%= err["failure"] %></span>
6
+ <div class="pf-flex pf-flex-col pf-gap-1 <%= 'pf-opacity-60' if dimmed %>">
7
+ <span class="pf-text-xl pf-font-normal pf-text-body pf-break-all"><%= err["url"] %></span>
8
+ <span class="pf-text-sm pf-font-normal pf-text-muted"><%= err["method"] %> &mdash; <%= err["failure"] %></span>
9
9
  </div>
10
10
  <% unless i == errors.size - 1 %>
11
- <hr class="border-subtle">
11
+ <hr>
12
12
  <% end %>
13
13
  <% end %>
14
14
  </div>
@@ -1,60 +1,59 @@
1
- <div class="flex flex-col gap-6 w-full">
2
- <div class="flex flex-col md:flex-row items-start md:items-center self-stretch justify-between gap-4">
3
- <h2 class="text-5xl font-extrabold leading-[120%] text-primary">Synthetic Runs</h2>
1
+ <div class="pf-flex pf-flex-col pf-gap-6 pf-w-full">
2
+ <div class="pf-flex pf-flex-col pf-md:pf-flex-row pf-items-start pf-md:pf-items-center pf-self-stretch pf-justify-between pf-gap-4">
3
+ <h2 class="pf-text-5xl pf-font-extrabold pf-leading-tight pf-text-primary">Synthetic Runs</h2>
4
4
 
5
- <%= form_with url: synthetic_runs_path, method: :get, class: "flex flex-row gap-3 items-center" do |f| %>
5
+ <%= form_with url: synthetic_runs_path, method: :get, class: "pf-flex pf-flex-row pf-gap-3 pf-items-center" do |f| %>
6
6
  <%= f.select :status,
7
7
  Perchfall::Rails::SyntheticRun::STATUSES.map { |status| [status.capitalize, status] },
8
8
  { include_blank: "All statuses", selected: params[:status] },
9
- class: "text-sm font-medium text-muted border border-neutral-150 rounded-lg px-3 py-2 bg-neutral-0" %>
9
+ class: "pf-select pf-text-sm pf-font-semibold pf-text-muted" %>
10
10
  <%= f.text_field :url,
11
11
  placeholder: "Filter by URL",
12
12
  value: params[:url],
13
- class: "text-sm text-neutral-900 border border-neutral-150 rounded-lg px-3 py-2 bg-neutral-0 w-64" %>
14
- <%= f.submit "Filter",
15
- class: "text-sm font-semibold text-primary border border-neutral-150 rounded-lg px-4 py-2 bg-neutral-0 cursor-pointer" %>
13
+ class: "pf-input pf-text-sm pf-w-64" %>
14
+ <%= f.submit "Filter", class: "pf-button pf-text-sm" %>
16
15
  <% end %>
17
16
  </div>
18
17
 
19
- <div class="p-8 border border-solid border-neutral-150 rounded-2xl bg-neutral-0 flex flex-col gap-6">
20
- <div class="grid grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] gap-4 items-center">
21
- <span class="text-lg font-semibold leading-[140%] text-muted">URL / Scenario</span>
22
- <span class="text-lg font-semibold leading-[140%] text-muted">Status</span>
23
- <span class="text-lg font-semibold leading-[140%] text-muted text-right">HTTP</span>
24
- <span class="text-lg font-semibold leading-[140%] text-muted text-right">Duration</span>
25
- <span class="text-lg font-semibold leading-[140%] text-muted text-right">Net errs</span>
26
- <span class="text-lg font-semibold leading-[140%] text-muted text-right">When</span>
18
+ <div class="pf-p-8 pf-border pf-border-subtle pf-rounded-2xl pf-bg-surface pf-flex pf-flex-col pf-gap-6">
19
+ <div class="pf-grid pf-grid-runs pf-gap-4 pf-items-center">
20
+ <span class="pf-text-lg pf-font-semibold pf-leading-normal pf-text-muted">URL / Scenario</span>
21
+ <span class="pf-text-lg pf-font-semibold pf-leading-normal pf-text-muted">Status</span>
22
+ <span class="pf-text-lg pf-font-semibold pf-leading-normal pf-text-muted pf-text-right">HTTP</span>
23
+ <span class="pf-text-lg pf-font-semibold pf-leading-normal pf-text-muted pf-text-right">Duration</span>
24
+ <span class="pf-text-lg pf-font-semibold pf-leading-normal pf-text-muted pf-text-right">Net errs</span>
25
+ <span class="pf-text-lg pf-font-semibold pf-leading-normal pf-text-muted pf-text-right">When</span>
27
26
  </div>
28
27
 
29
- <hr class="border-subtle">
28
+ <hr>
30
29
 
31
30
  <% if @runs.none? %>
32
- <p class="text-xl font-normal text-muted">No runs found.</p>
31
+ <p class="pf-text-xl pf-font-normal pf-text-muted">No runs found.</p>
33
32
  <% end %>
34
33
 
35
34
  <% @runs.each_with_index do |run, i| %>
36
- <div class="grid grid-cols-[2fr_1fr_1fr_1fr_1fr_1fr] gap-4 items-center">
37
- <div class="flex flex-col gap-0.5">
38
- <%= link_to synthetic_run_path(run), class: "text-xl font-normal leading-[140%] text-primary underline truncate" do %>
35
+ <div class="pf-grid pf-grid-runs pf-gap-4 pf-items-center">
36
+ <div class="pf-flex pf-flex-col pf-gap-0-5">
37
+ <%= link_to synthetic_run_path(run), class: "pf-text-xl pf-font-normal pf-leading-normal pf-text-primary pf-underline pf-truncate" do %>
39
38
  <%= run.scenario_name || run.url %>
40
39
  <% end %>
41
40
  <% if run.scenario_name.present? %>
42
- <span class="text-sm font-normal text-muted"><%= run.url %></span>
41
+ <span class="pf-text-sm pf-font-normal pf-text-muted"><%= run.url %></span>
43
42
  <% end %>
44
43
  </div>
45
44
 
46
- <span class="<%= run.ok? ? 'text-primary' : 'text-error' %> text-xl font-semibold leading-[140%]">
45
+ <span class="<%= run.ok? ? 'pf-text-primary' : 'pf-text-error' %> pf-text-xl pf-font-semibold pf-leading-normal">
47
46
  <%= run.status.capitalize %>
48
47
  </span>
49
48
 
50
- <span class="text-xl font-normal leading-[140%] text-neutral-900 text-right"><%= run.http_status || "—" %></span>
51
- <span class="text-xl font-normal leading-[140%] text-neutral-900 text-right"><%= run.duration_ms ? "#{run.duration_ms}ms" : "—" %></span>
52
- <span class="text-xl font-normal leading-[140%] text-neutral-900 text-right"><%= run.network_error_count %></span>
53
- <span class="text-xl font-normal leading-[140%] text-muted text-right"><%= time_ago_in_words(run.created_at) %> ago</span>
49
+ <span class="pf-text-xl pf-font-normal pf-leading-normal pf-text-body pf-text-right"><%= run.http_status || "—" %></span>
50
+ <span class="pf-text-xl pf-font-normal pf-leading-normal pf-text-body pf-text-right"><%= run.duration_ms ? "#{run.duration_ms}ms" : "—" %></span>
51
+ <span class="pf-text-xl pf-font-normal pf-leading-normal pf-text-body pf-text-right"><%= run.network_error_count %></span>
52
+ <span class="pf-text-xl pf-font-normal pf-leading-normal pf-text-muted pf-text-right"><%= time_ago_in_words(run.created_at) %> ago</span>
54
53
  </div>
55
54
 
56
55
  <% unless i == @runs.size - 1 %>
57
- <hr class="border-subtle">
56
+ <hr>
58
57
  <% end %>
59
58
  <% end %>
60
59
  </div>
@@ -1,65 +1,65 @@
1
- <div class="flex flex-col gap-6 w-full">
2
- <div class="flex flex-row items-center gap-4">
3
- <%= link_to synthetic_runs_path, class: "text-muted hover:text-primary text-lg w-full" do %>
1
+ <div class="pf-flex pf-flex-col pf-gap-6 pf-w-full">
2
+ <div class="pf-flex pf-flex-row pf-items-center pf-gap-4">
3
+ <%= link_to synthetic_runs_path, class: "pf-text-muted pf-hover:pf-text-primary pf-text-lg pf-w-full" do %>
4
4
  &larr; All runs
5
5
  <% end %>
6
6
  </div>
7
- <div class="flex flex-row items-center gap-4">
8
- <h2 class="text-5xl font-extrabold leading-[120%] text-primary w-full">
7
+ <div class="pf-flex pf-flex-row pf-items-center pf-gap-4">
8
+ <h2 class="pf-text-5xl pf-font-extrabold pf-leading-tight pf-text-primary pf-w-full">
9
9
  <%= @run.scenario_name.presence || @run.url %>
10
10
  </h2>
11
11
  </div>
12
12
 
13
- <div class="flex flex-col md:flex-row gap-4 self-stretch items-start">
13
+ <div class="pf-flex pf-flex-col pf-md:pf-flex-row pf-gap-4 pf-self-stretch pf-items-start">
14
14
  <%# Summary card %>
15
- <div class="p-8 border border-solid border-neutral-150 rounded-2xl bg-neutral-0 flex flex-col gap-6 flex-1">
16
- <h3 class="text-3xl font-extrabold leading-[120%] text-primary">Summary</h3>
15
+ <div class="pf-p-8 pf-border pf-border-subtle pf-rounded-2xl pf-bg-surface pf-flex pf-flex-col pf-gap-6 pf-flex-1">
16
+ <h3 class="pf-text-3xl pf-font-extrabold pf-leading-tight pf-text-primary">Summary</h3>
17
17
 
18
- <dl class="flex flex-col gap-4">
19
- <div class="grid grid-cols-2 gap-4">
20
- <dt class="text-lg font-semibold text-muted">Status</dt>
21
- <dd class="text-xl font-semibold <%= @run.ok? ? 'text-primary' : 'text-error' %>">
18
+ <dl class="pf-flex pf-flex-col pf-gap-4">
19
+ <div class="pf-grid pf-grid-summary pf-gap-4">
20
+ <dt class="pf-text-lg pf-font-semibold pf-text-muted">Status</dt>
21
+ <dd class="pf-text-xl pf-font-semibold <%= @run.ok? ? 'pf-text-primary' : 'pf-text-error' %>">
22
22
  <%= @run.status.capitalize %>
23
23
  </dd>
24
24
  </div>
25
- <hr class="border-subtle">
26
- <div class="grid grid-cols-2 gap-4">
27
- <dt class="text-lg font-semibold text-muted">Scenario</dt>
28
- <dd class="text-xl font-normal text-neutral-900 break-all"><%= @run.scenario_name %></dd>
25
+ <hr>
26
+ <div class="pf-grid pf-grid-summary pf-gap-4">
27
+ <dt class="pf-text-lg pf-font-semibold pf-text-muted">Scenario</dt>
28
+ <dd class="pf-text-xl pf-font-normal pf-text-body pf-break-all"><%= @run.scenario_name %></dd>
29
29
  </div>
30
- <hr class="border-subtle">
30
+ <hr>
31
31
  <% if @run.scenario_name.present? %>
32
- <div class="grid grid-cols-2 gap-4">
33
- <dt class="text-lg font-semibold text-muted">URL</dt>
34
- <dd class="text-xl font-normal text-neutral-900"><%= @run.url %></dd>
32
+ <div class="pf-grid pf-grid-summary pf-gap-4">
33
+ <dt class="pf-text-lg pf-font-semibold pf-text-muted">URL</dt>
34
+ <dd class="pf-text-xl pf-font-normal pf-text-body"><%= @run.url %></dd>
35
35
  </div>
36
- <hr class="border-subtle">
36
+ <hr>
37
37
  <% end %>
38
- <div class="grid grid-cols-2 gap-4">
39
- <dt class="text-lg font-semibold text-muted">HTTP status</dt>
40
- <dd class="text-xl font-normal text-neutral-900"><%= @run.http_status || "—" %></dd>
38
+ <div class="pf-grid pf-grid-summary pf-gap-4">
39
+ <dt class="pf-text-lg pf-font-semibold pf-text-muted">HTTP status</dt>
40
+ <dd class="pf-text-xl pf-font-normal pf-text-body"><%= @run.http_status || "—" %></dd>
41
41
  </div>
42
- <hr class="border-subtle">
43
- <div class="grid grid-cols-2 gap-4">
44
- <dt class="text-lg font-semibold text-muted">Duration</dt>
45
- <dd class="text-xl font-normal text-neutral-900"><%= @run.duration_ms ? "#{@run.duration_ms}ms" : "—" %></dd>
42
+ <hr>
43
+ <div class="pf-grid pf-grid-summary pf-gap-4">
44
+ <dt class="pf-text-lg pf-font-semibold pf-text-muted">Duration</dt>
45
+ <dd class="pf-text-xl pf-font-normal pf-text-body"><%= @run.duration_ms ? "#{@run.duration_ms}ms" : "—" %></dd>
46
46
  </div>
47
- <hr class="border-subtle">
48
- <div class="grid grid-cols-2 gap-4">
49
- <dt class="text-lg font-semibold text-muted">Run at</dt>
50
- <dd class="text-xl font-normal text-neutral-900"><%= @run.created_at.strftime("%b %-d, %Y at %H:%M UTC") %></dd>
47
+ <hr>
48
+ <div class="pf-grid pf-grid-summary pf-gap-4">
49
+ <dt class="pf-text-lg pf-font-semibold pf-text-muted">Run at</dt>
50
+ <dd class="pf-text-xl pf-font-normal pf-text-body"><%= @run.created_at.strftime("%b %-d, %Y at %H:%M UTC") %></dd>
51
51
  </div>
52
52
  </dl>
53
53
  </div>
54
54
 
55
- <div class="flex flex-col gap-4 flex-1">
55
+ <div class="pf-flex pf-flex-col pf-gap-4 pf-flex-1">
56
56
  <% if @run.network_errors.any? %>
57
57
  <%= render "network_error_card",
58
58
  errors: @run.network_errors,
59
59
  title: "Network Errors",
60
60
  count: @run.network_error_count,
61
- heading_color: "text-primary",
62
- count_color: "text-error",
61
+ heading_color: "pf-text-primary",
62
+ count_color: "pf-text-error",
63
63
  dimmed: false %>
64
64
  <% end %>
65
65
 
@@ -68,15 +68,15 @@
68
68
  errors: @run.console_errors,
69
69
  title: "Console Errors",
70
70
  count: @run.console_error_count,
71
- heading_color: "text-primary",
72
- count_color: "text-error",
71
+ heading_color: "pf-text-primary",
72
+ count_color: "pf-text-error",
73
73
  dimmed: false %>
74
74
  <% end %>
75
75
 
76
76
  <% if @run.network_errors.none? && @run.console_errors.none? %>
77
- <div class="p-8 border border-solid border-neutral-150 rounded-2xl bg-neutral-0 flex flex-col gap-2">
78
- <h3 class="text-3xl font-extrabold leading-[120%] text-primary">No errors</h3>
79
- <p class="text-xl font-normal text-muted">This run completed cleanly.</p>
77
+ <div class="pf-p-8 pf-border pf-border-subtle pf-rounded-2xl pf-bg-surface pf-flex pf-flex-col pf-gap-2">
78
+ <h3 class="pf-text-3xl pf-font-extrabold pf-leading-tight pf-text-primary">No errors</h3>
79
+ <p class="pf-text-xl pf-font-normal pf-text-muted">This run completed cleanly.</p>
80
80
  </div>
81
81
  <% end %>
82
82
 
@@ -85,8 +85,8 @@
85
85
  errors: @run.ignored_network_errors,
86
86
  title: "Ignored Network Errors",
87
87
  count: @run.ignored_network_errors.size,
88
- heading_color: "text-muted",
89
- count_color: "text-subtle",
88
+ heading_color: "pf-text-muted",
89
+ count_color: "pf-text-subtle",
90
90
  dimmed: true %>
91
91
  <% end %>
92
92
 
@@ -95,8 +95,8 @@
95
95
  errors: @run.ignored_console_errors,
96
96
  title: "Ignored Console Errors",
97
97
  count: @run.ignored_console_errors.size,
98
- heading_color: "text-muted",
99
- count_color: "text-subtle",
98
+ heading_color: "pf-text-muted",
99
+ count_color: "pf-text-subtle",
100
100
  dimmed: true %>
101
101
  <% end %>
102
102
  </div>
@@ -8,4 +8,34 @@ Perchfall::Rails.configure do |config|
8
8
 
9
9
  # ActiveJob queue name for synthetic run jobs. Defaults to :synthetic.
10
10
  # config.queue_name = :synthetic
11
+
12
+ # Optional host stylesheet (no extension) loaded after the engine CSS.
13
+ # Use to override --perchfall-* CSS custom properties for theming.
14
+ # config.overrides_stylesheet = "perchfall_rails_overrides"
15
+
16
+ # Optional callable invoked after each SyntheticRun is persisted. Receives
17
+ # the persisted run as a single positional argument. Fires on the happy
18
+ # path and on RunJob's error rescues — once per persisted run, which under
19
+ # ActiveJob retries means once per attempt. Use run.id to route downstream
20
+ # work to that specific persisted run (e.g. SomeJob.perform_later(run.id))
21
+ # — each retry attempt persists a distinct id, so run.id does NOT dedup
22
+ # across attempts. The engine provides no cross-attempt key today, so
23
+ # handlers that need once-per-failure-event semantics (notifications,
24
+ # ticket-opens) should be idempotent on the host's natural keys, or
25
+ # should accept at-least-once. Exceptions are rescued and logged; they
26
+ # do not interrupt the pipeline. See ADR 0006.
27
+ # config.after_persist = ->(run) { SomeJob.perform_later(run.id) if run.failed? }
28
+
29
+ # Optional name of a host module the engine includes into
30
+ # Perchfall::Rails::SyntheticRun from a config.to_prepare block on boot
31
+ # (and re-includes on every code reload in development). Use to declare
32
+ # host-side associations, scopes, or callbacks (`belongs_to :user`, &c.).
33
+ # The named module must be autoloadable.
34
+ # Callbacks added here run as part of the engine's SyntheticRun.create!; if
35
+ # one raises, the create fails, the job dies, no SyntheticRun is written
36
+ # (not even an error row), and after_persist does not fire. Reserve extension
37
+ # callbacks for declarations and logic that has been hardened against
38
+ # raising; for observability-style side effects, use after_persist. See
39
+ # ADR 0007.
40
+ # config.synthetic_run_extension = "SyntheticRunExtension"
11
41
  end
@@ -3,11 +3,14 @@
3
3
  module Perchfall
4
4
  module Rails
5
5
  class Configuration
6
- attr_accessor :base_controller, :queue_name
6
+ attr_accessor :after_persist, :base_controller, :overrides_stylesheet, :queue_name, :synthetic_run_extension
7
7
 
8
8
  def initialize
9
+ @after_persist = nil
9
10
  @base_controller = "ApplicationController"
11
+ @overrides_stylesheet = nil
10
12
  @queue_name = :synthetic
13
+ @synthetic_run_extension = nil
11
14
  end
12
15
  end
13
16
 
@@ -21,6 +21,14 @@ module Perchfall
21
21
  Perchfall::Rails::RegexpSerializer,
22
22
  Perchfall::Rails::IgnoreRuleSerializer
23
23
  )
24
+
25
+ # ADR 0007: include the host-provided extension module into SyntheticRun.
26
+ # config.to_prepare fires after all host initializers (so the config is
27
+ # set), after eager_load in production (so the model is loaded), and on
28
+ # every code reload in development (so the include re-applies after
29
+ # Zeitwerk swaps the class). Module#include is idempotent on its own.
30
+ extension = Perchfall::Rails.configuration.synthetic_run_extension
31
+ Perchfall::Rails::SyntheticRun.include(extension.constantize) if extension
24
32
  end
25
33
 
26
34
  initializer "perchfall.node_path" do
@@ -28,6 +36,15 @@ module Perchfall
28
36
  existing = ENV["NODE_PATH"].to_s
29
37
  ENV["NODE_PATH"] = [local_node_modules, existing].reject(&:empty?).join(":")
30
38
  end
39
+
40
+ initializer "perchfall.rails.assets" do |app|
41
+ if app.config.respond_to?(:assets)
42
+ app.config.assets.paths << root.join("app/assets/stylesheets").to_s
43
+ if app.config.assets.respond_to?(:precompile)
44
+ app.config.assets.precompile << "perchfall/rails/application.css"
45
+ end
46
+ end
47
+ end
31
48
  end
32
49
  end
33
50
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Perchfall
4
4
  module Rails
5
- VERSION = "0.2.2"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: perchfall-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yossef Mendelssohn
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-06-13 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: pagy
@@ -66,7 +67,7 @@ files:
66
67
  - LICENSE.txt
67
68
  - README.md
68
69
  - Rakefile
69
- - app/assets/tailwind/perchfall_rails/engine.css
70
+ - app/assets/stylesheets/perchfall/rails/application.css
70
71
  - app/controllers/perchfall/rails/synthetic_runs_controller.rb
71
72
  - app/jobs/perchfall/rails/run_job.rb
72
73
  - app/models/perchfall/rails/synthetic_run.rb
@@ -123,7 +124,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
123
124
  - !ruby/object:Gem::Version
124
125
  version: '0'
125
126
  requirements: []
126
- rubygems_version: 4.0.6
127
+ rubygems_version: 3.0.3.1
128
+ signing_key:
127
129
  specification_version: 4
128
130
  summary: Rails engine for integrating Perchfall synthetic monitoring
129
131
  test_files: []
@@ -1 +0,0 @@
1
- @source "../../../views/**/*.html.erb";