Build a Rails Incident Triage Dashboard

Build a Rails dashboard and fix an N+1 query with tested eager loading.

Introduction

30 Second Summary

A dashboard can show every incident correctly while quietly repeating the same work for each row. That hidden cost surfaces as soon as the queue grows.

In this project, you will build a BookNotes incident triage dashboard with Ruby on Rails plus PostgreSQL. You'll expose a hidden N+1 query with strict loading before replacing it with an eager-loading fix measured by automated tests.

What You'll Build

You'll refresh the BookNotes dashboard to see an incident queue where every row names its affected service.

By the end of this project, you'll have:

  • Scan the incident queue for each incident's severity, response status, and affected service.
  • Apply allowlisted status filters that narrow the table only for known values.
  • Present a measured performance fix through a strict-loading failure, a two-query regression test, and an architecture decision record.
  • Secret Mission: Add a validated severity filter while preserving the optimized query behavior.

Are there any prerequisites?

This project suits learners with 3 to 4 years of Rails experience on a Mac that already has Ruby 3.2 or newer, Ruby on Rails 8.1.4, and PostgreSQL. Step 1 verifies Git, Visual Studio Code, and Safari before the build starts.

Before We Start

Before the hands-on work begins, lock in what you are building and why it matters. You are connecting a PostgreSQL-backed Rails incident dashboard to a measurable N+1 query fix that demonstrates senior-level performance ownership.

Prepare the Rails Workspace

Your BookNotes dashboard needs a reliable local baseline before any incident code appears. A mismatch between Ruby and Ruby on Rails can make a database problem look like an application problem.

You’ll verify PostgreSQL and Git before generating the app in Visual Studio Code. You’ll then prove Safari can reach the local Rails server.

In this step, get ready to:
  • Confirm the required Ruby version plus the Rails and Git installations.
  • Generate the PostgreSQL-backed booknotes application in Visual Studio Code.
  • Create the development database and display the Rails welcome page in Safari.
Verify the local toolchain

Rails 8.1.4 requires Ruby 3.2 or newer. Checking each version now gives your later performance measurements a known runtime.

  • Press Cmd+Space to open macOS search.
  • Type Terminal and press Enter to open it.
  • Check Ruby, Rails, and Git by running these commands:
ruby --version
rails --version
git --version

What do these checks prove?

  • The ruby --version result identifies the runtime that executes Rails.
  • The rails --version result identifies the framework release that generates your application.
  • The git --version result confirms source control is available for the generated project.

✔️ I see the required Ruby version

Your Ruby result shows 3.2 or newer. The runtime is ready for Rails 8.1.4.

ⓧ I see an older Ruby version

The installed Ruby version cannot run Rails 8.1.4. Use the official macOS installation path to upgrade the runtime.

  • Open the official Rails installation guide.
  • Select the macOS instructions on the installation guide.
  • Install Ruby 3.2 or newer by following that path.
  • Return to Terminal after the installation completes.
  • Repeat the Ruby version check from above.

ⓧ Ruby command not found

Terminal cannot find a Ruby installation. The official Rails installation guide provides the supported macOS setup path.

  • Open the official Rails installation guide.
  • Select the macOS instructions on the installation guide.
  • Install Ruby 3.2 or newer by following that path.
  • Return to Terminal after the installation completes.
  • Repeat the Ruby version check from above.

✔️ I see the required Rails version

Your Rails result shows 8.1.4. The scaffold you generate in this step uses the intended framework version.

ⓧ I see an older Rails version

The older Rails installation needs an upgrade before you generate the application. Expect the installation to take a few minutes while the framework packages download.

  • Install the current Rails release by running this command:
gem install rails

What does this command do?

The command asks RubyGems to install the current stable Rails release. On the project date, that release is 8.1.4.

  • Verify the upgraded Rails installation by running:
rails --version

What does this check confirm?

The command reports the Rails executable that Terminal now finds. You need to see Rails 8.1.4 before continuing.

ⓧ Rails command not found

Ruby is available while Rails is missing. Expect the installation to take a few minutes while the framework packages download.

  • Install Rails by running this command:
gem install rails

What does this command install?

The command installs the current stable Rails gem for your Ruby environment. On the project date, the installed release is 8.1.4.

  • Verify the Rails installation by running:
rails --version

What does this check confirm?

The command confirms Terminal can find Rails. You need to see Rails 8.1.4 before generating the app.

Having trouble installing Rails?

Confirm the Ruby check reports 3.2 or newer. A different Ruby installation can place the Rails gem outside the command path used by your current Terminal session.

Close Terminal after the installation finishes. Open a fresh Terminal window before checking the version again.

Help me diagnose my Ruby and Rails installation.

✔️ I see a Git version

Git is available in Terminal. Your generated Rails project can use local source control from the start.

ⓧ Git command not found

Git comes with Apple’s Xcode Command Line Tools package. Expect the installation to take several minutes after macOS opens the installer.

  • Start the Xcode Command Line Tools installer by running:
xcode-select --install

What does this command install?

The command opens Apple’s installer for the Xcode Command Line Tools package. That package includes Git.

  • Approve the installation when macOS asks.
  • Return to Terminal after the installation completes.
  • Verify Git by running:
git --version

What does this check confirm?

The command confirms that Terminal can now find Git. A displayed version means the installation succeeded.

Git still unavailable?

Close Terminal after the installer finishes. Open a fresh Terminal window so the shell can find the newly installed tools.

Help me troubleshoot Git after installing the Xcode Command Line Tools.

Set up Visual Studio Code and generate the app

The Rails generator creates the application structure for you. Its PostgreSQL option adds the database adapter plus the matching configuration.

✔️ I can open Visual Studio Code

  • Press Cmd+Space to open macOS search.
  • Type Visual Studio Code and press Enter to open it.

You’ll see the Visual Studio Code window. Next, make its terminal command available across macOS.

  • Press Cmd+Shift+P to open the Command Palette.
  • Run Shell Command: Install 'code' command in PATH.

Visual Studio Code confirms that the code command is available after you restart Terminal.

ⓧ Visual Studio Code is not installed

The downloaded .dmg file appears in your Downloads folder.

  • Open the downloaded .dmg file.
  • Drag Visual Studio Code.app into the Applications folder.

Visual Studio Code is now installed in your Applications folder.

  • Press Cmd+Space to open macOS search.
  • Type Visual Studio Code and press Enter to open it.

You’ll see the Visual Studio Code window. The final editor setup makes its command available in Terminal.

  • Press Cmd+Shift+P to open the Command Palette.
  • Run Shell Command: Install 'code' command in PATH.

Visual Studio Code confirms that the code command is available after you restart Terminal.

  • Close the Terminal window from earlier.

A fresh Terminal session picks up the updated command path.

  • Press Cmd+Space to open macOS search.
  • Type Terminal and press Enter to open it.

You’ll see a fresh Terminal window ready to create the project.

  • Move to your Desktop by running this command:
cd ~/Desktop

Why start on the Desktop?

The ~ symbol represents your home folder. Starting on the Desktop gives the generated booknotes folder a predictable location.

The scaffold can take a few minutes while Rails creates files and installs its bundled dependencies. A steady stream of generated file names means it is working.

  • Generate the PostgreSQL-backed application by running this command:
rails new booknotes --database=postgresql

What does the scaffold create?

  • The generator creates the booknotes application folder plus Rails directories such as app/, config/, db/, and test/.
  • The --database=postgresql option adds the pg gem and generates PostgreSQL database configuration.
  • The generated bin/rails command keeps future tasks tied to the Rails version bundled with this app.

The generator finishes by returning control to your Terminal prompt. That is the first major piece complete: the Rails application shell now exists on your Desktop.

Did the scaffold stop early?

Confirm the earlier checks report Ruby 3.2 or newer plus Rails 8.1.4. Also confirm PostgreSQL is running before the later database command.

Help me diagnose why the Rails PostgreSQL scaffold failed.

The first editor launch may trigger a macOS confirmation. Approving it allows Visual Studio Code to open the generated folder.

  • Approve the macOS launch confirmation if it appears after the next command.
  • Open the generated booknotes folder in Visual Studio Code by running these commands:
cd booknotes
code .

What did these commands do?

The first command moves Terminal into the generated booknotes folder. The second command opens that current folder in Visual Studio Code.

You’ll see the generated Rails files in Visual Studio Code’s Explorer sidebar. Look for Gemfile plus the app/, config/, db/, and test/ folders.

Launch the app and verify the browser

The generated files only prove that the scaffold completed. Creating the database and serving a browser request prove that Rails can reach PostgreSQL in this environment.

  • Switch back to the Terminal window from earlier.
  • Create the application database by running:
bin/rails db:create

What does this command do?

The bin/rails executable uses the Rails version bundled with booknotes. The database task creates the local PostgreSQL database required by the development environment.

The command should complete without a database connection error. That result confirms Rails can reach PostgreSQL.

Database creation did not complete?

Confirm your PostgreSQL service is running on the Mac. Also confirm Terminal is inside the booknotes folder that contains bin/rails.

Help me diagnose why Rails cannot create the PostgreSQL database.

Before you start the server, do you expect Rails to boot cleanly after the successful database check?

  • Start the local Rails server by running:
bin/rails server

What does the server command do?

The command starts Puma on port 3000 by default. The process keeps this Terminal window busy until you stop it with Control-C.

  • Leave the server running in the Terminal window.
  • Press Cmd+Space to open macOS search.
  • Type Safari and press Enter to open it.

You’ll see a Safari window ready to request the local application.

  • Enter http://localhost:3000 in Safari’s address bar.

You’ll see the Rails welcome page without a database connection error. Your browser can now reach the PostgreSQL-backed application through the local Rails server.

Rails welcome page not loading?

Confirm the Terminal window still shows the running Rails server. Check that Safari is using http://localhost:3000.

If the terminal mentions a database problem, stop the server with Control-C. Confirm PostgreSQL is running before repeating the database creation and server commands.

Help me debug why Safari cannot load my local Rails welcome page.

✔️ Awesome, I've got everything!

Your generated Rails application is open in Visual Studio Code. Keep the server running for the next step.

ⓧ I'd like to double check the full code

This step uses the generated Rails scaffold. Double-check the application state instead of comparing a hand-written file.

  • Confirm the booknotes folder is a generated Rails 8.1.4 PostgreSQL application directory.
  • Confirm the development database creation completed successfully.
  • Confirm Visual Studio Code displays the generated application files.
  • Confirm Safari displays the Rails welcome page at http://localhost:3000.

That foundation is in place: your PostgreSQL-backed Rails app is running in Safari. Next up, you’ll model services and incidents for the first dashboard.

Build the First Incident Dashboard

The Rails welcome page proves that your application can reach PostgreSQL. BookNotes now needs real incident data that an engineering lead can scan in Safari.

A useful-looking dashboard can still hide inefficient database access. You will begin with an intentionally simple Active Record query so the repeated service lookups appear in the development log.

In this step, get ready to:
  • Create the service and incident data model.
  • Seed six incidents and render the dashboard.
  • Style the dashboard and inspect its database queries.
Create the incident data model

A database foreign key connects every incident to one service. Active Record associations expose the same relationship through the Service and Incident models.

  • Switch back to the Rails server terminal from the previous step.
  • Stop the server by pressing Control-C.
  • In the Visual Studio Code file sidebar, create 20261016000001_create_services.rb inside db/migrate.
  • Add the service table definition by pasting this code:
class CreateServices < ActiveRecord::Migration[8.1]
  def change
    create_table :services do |t|
      t.string :name, null: false

      t.timestamps
    end
  end
end

What does this migration create?

  • The services table stores each affected system by name.
  • The null: false constraint requires every service row to have a name.
  • The timestamps record when each service row is created or updated.
  • Save db/migrate/20261016000001_create_services.rb.
  • Confirm the migration appears inside db/migrate in the file sidebar.

Migration file missing?

Check that the file path ends with db/migrate/20261016000001_create_services.rb. Confirm that the class inside the file is named CreateServices.

Ask for help with the migration path. Help me check my service migration file.

  • In the Visual Studio Code file sidebar, create 20261016000002_create_incidents.rb inside db/migrate.
  • Add the incident table definition by pasting this code:
class CreateIncidents < ActiveRecord::Migration[8.1]
  def change
    create_table :incidents do |t|
      t.references :service, null: false, foreign_key: true
      t.string :title, null: false
      t.string :severity, null: false
      t.string :status, null: false

      t.timestamps
    end
  end
end

How are incidents connected?

  • The service reference creates an indexed service_id column.
  • The foreign key prevents an incident from pointing to a missing service.
  • The remaining columns hold the title plus the two triage values shown on the dashboard.
  • Save db/migrate/20261016000002_create_incidents.rb.
  • Confirm both migration files appear inside db/migrate.
  • Apply both migrations to the development database by running:
bin/rails db:migrate

What does this command change?

Rails applies the pending migrations in timestamp order. It also updates db/schema.rb to describe the resulting database structure.

You should see both migration names in the terminal before the prompt returns. The services and incidents tables now exist.

Migration did not complete?

  • Check each migration for matching do and end keywords.
  • Check that PostgreSQL is still running on your Mac.
  • Read the first file path named in the terminal output.

Ask for help with the migration output. Help me diagnose my Rails migration failure.

  • In the Visual Studio Code file sidebar, create service.rb inside app/models.
  • Add the service association and validation by pasting this code:
class Service < ApplicationRecord
  has_many :incidents, dependent: :destroy

  validates :name, presence: true
end

What does the Service model enforce?

  • The has_many association exposes a service's incidents.
  • The dependent option removes associated incidents when their service is destroyed.
  • The validation rejects a blank service name before Rails writes the record.
  • Save app/models/service.rb.
  • Confirm service.rb appears inside app/models.

Service model not loading?

Confirm the filename is lowercase service.rb. Confirm the class inside it is singular Service.

Ask for help with the model naming. Help me review my Service model.

  • In the Visual Studio Code file sidebar, create incident.rb inside app/models.
  • Add the incident allowlists plus its association and validations by pasting this code:
class Incident < ApplicationRecord
  STATUSES = %w[open investigating resolved].freeze
  SEVERITIES = %w[critical high medium low].freeze

  belongs_to :service

  validates :title, :severity, :status, presence: true
  validates :severity, inclusion: { in: SEVERITIES }
  validates :status, inclusion: { in: STATUSES }
end

Why use allowlists?

The constants define the status and severity values accepted by the application. The inclusion validations stop unknown values from entering the incident queue.

The belongs_to association gives each incident access to its service. That access path becomes important when you inspect the dashboard queries.

  • Save app/models/incident.rb.
  • Confirm incident.rb appears beside service.rb.

Incident model not loading?

Compare the values in STATUSES with the status validation. Compare the values in SEVERITIES with the severity validation.

Ask for help with the model structure. Help me review my Incident model.

Seed and render the dashboard

Seed data gives the dashboard a repeatable incident queue. Each incident points to a named service so the table can display the association.

  • Switch to db/seeds.rb in the Visual Studio Code file sidebar.
  • Replace its contents with the four services and first three incidents below:
catalog = Service.find_or_create_by!(name: "Catalog")
identity = Service.find_or_create_by!(name: "Identity")
media = Service.find_or_create_by!(name: "Media")
notifications = Service.find_or_create_by!(name: "Notifications")

Incident.find_or_create_by!(title: "Login token failures", service: identity) do |incident|
  incident.severity = "critical"
  incident.status = "open"
end

Incident.find_or_create_by!(title: "Cover image processing errors", service: media) do |incident|
  incident.severity = "high"
  incident.status = "open"
end

Incident.find_or_create_by!(title: "Search indexing delay", service: catalog) do |incident|
  incident.severity = "high"
  incident.status = "investigating"
end

How does the seed stay repeatable?

Each find_or_create_by! call reuses a matching record when one already exists. Repeated seed runs do not duplicate these services or incidents.

  • Save db/seeds.rb.
  • Load the first part of the dataset by running:
bin/rails db:seed

What does the seed command do?

Rails evaluates db/seeds.rb against the development database. A successful run returns control to the terminal without an exception.

First seed run failed?

  • Check that both model files are saved.
  • Check every incident status against STATUSES.
  • Check every incident severity against SEVERITIES.

Ask for help with the seed failure. Help me diagnose my first seed run.

  • Append the remaining three incident records beneath the existing seed data:
Incident.find_or_create_by!(title: "Email digest backlog", service: notifications) do |incident|
  incident.severity = "medium"
  incident.status = "investigating"
end

Incident.find_or_create_by!(title: "Password reset delivery delays", service: notifications) do |incident|
  incident.severity = "medium"
  incident.status = "resolved"
end

Incident.find_or_create_by!(title: "Export latency", service: catalog) do |incident|
  incident.severity = "low"
  incident.status = "resolved"
end

What does the full dataset cover?

The six incidents cover every allowed status. Their severity values span critical through low.

That spread gives the dashboard meaningful summary values and badge colors.

  • Save db/seeds.rb.
  • Load the complete dataset by running:
bin/rails db:seed

What should happen now?

The command reuses the first three incidents. It adds the final three records before returning to the prompt.

Complete seed run failed?

Check that the second code block sits below the first block in db/seeds.rb. Confirm that every block closes with end.

Ask for help with the completed seed file. Help me review all six incident seeds.

  • In the Visual Studio Code file sidebar, create the app/views/incidents folder.
  • Inside that folder, create index.html.erb.
  • Add the dashboard header plus the status links by pasting this code:
<div class="page-shell">
  <header class="page-header">
    <p class="eyebrow">BookNotes reliability</p>
    <h1>Incident Triage Dashboard</h1>
    <p>Review active incidents, affected services, and current response status.</p>
  </header>

  <nav class="filters" aria-label="Filter incidents by status">
    <%= link_to "All", root_path,
      class: "filter#{' active' if @selected_status.nil?}" %>

    <% Incident::STATUSES.each do |status| %>
      <%= link_to status.capitalize, root_path(status: status),
        class: "filter#{' active' if @selected_status == status}" %>
    <% end %>
  </nav>

How do the status links work?

The navigation creates one link for every value in Incident::STATUSES. The active class follows the value stored in @selected_status.

  • Save app/views/incidents/index.html.erb.
  • Confirm the header plus the status navigation remain inside the opening page-shell element.

Filter markup looks incomplete?

Confirm that every embedded Ruby tag has a closing delimiter. Keep the opening page-shell element in place because a later chunk closes it.

Ask for help with the first view section. Help me inspect my dashboard header and links.

  • Append the incident summary section beneath the status navigation:
  <section class="summary-grid" aria-label="Visible incident summary">
    <article class="metric">
      <span>Visible incidents</span>
      <strong><%= @incidents.size %></strong>
    </article>
    <article class="metric">
      <span>Open</span>
      <strong><%= @incidents.count { |incident| incident.status == "open" } %></strong>
    </article>
    <article class="metric">
      <span>Investigating</span>
      <strong><%= @incidents.count { |incident| incident.status == "investigating" } %></strong>
    </article>
  </section>

What do the metrics count?

The first card counts every incident in the relation. The other cards count open and investigating incidents in that same collection.

  • Save app/views/incidents/index.html.erb.
  • Confirm the three metric articles appear below the navigation in the editor.

Summary expressions incomplete?

Confirm every embedded Ruby expression begins with <%= and ends with %>.

Ask for help with the metric section. Help me review my ERB summary cards.

  • Append the incident table beneath the summary section:
  <div class="table-card">
    <table>
      <thead>
        <tr>
          <th>Incident</th>
          <th>Service</th>
          <th>Severity</th>
          <th>Status</th>
        </tr>
      </thead>
      <tbody>
        <% @incidents.each do |incident| %>
          <tr>
            <td><%= incident.title %></td>
            <td><%= incident.service.name %></td>
            <td>
              <span class="badge severity-<%= incident.severity %>">
                <%= incident.severity.capitalize %>
              </span>
            </td>
            <td><span class="status"><%= incident.status.capitalize %></span></td>
          </tr>
        <% end %>
      </tbody>
    </table>
  </div>
</div>

Where does the query problem begin?

The loop renders one row per incident. The expression incident.service.name follows the association to display the affected service.

The first controller query will load incidents without their services. Active Record must fetch service data while the rows render.

  • Save app/views/incidents/index.html.erb.
  • Confirm the final two closing div elements appear at the bottom of the file.

Table loop incomplete?

Confirm the loop begins with @incidents.each. Confirm it closes after the table row.

Ask for help with the table markup. Help me review my incident table loop.

  • In the Visual Studio Code file sidebar, create incidents_controller.rb inside app/controllers.
  • Add the first dashboard query by pasting this code:
class IncidentsController < ApplicationController
  def index
    @selected_status = params[:status]
    @incidents = Incident.order(created_at: :desc)
  end
end

How does the first query work?

The controller records the requested status in @selected_status. It loads incidents from newest to oldest through @incidents.

The relation leaves each service association unloaded. The development log will expose the extra work when the view requests each service name.

  • Save app/controllers/incidents_controller.rb.
  • Confirm the controller appears inside app/controllers.

Controller file in the wrong place?

Confirm the path is app/controllers/incidents_controller.rb. Confirm the class is named IncidentsController.

Ask for help with the controller naming. Help me check my incidents controller.

  • Switch to config/routes.rb in the Visual Studio Code file sidebar.
  • Replace its contents with the dashboard root route:
Rails.application.routes.draw do
  root "incidents#index"
end

What does the root route do?

The route sends requests for http://localhost:3000 to the index action in IncidentsController.

  • Save config/routes.rb.
  • Restart the application server by running:
bin/rails server

What should you watch now?

Rails serves the dashboard on port 3000. Keep the terminal visible because it records the database queries generated by each request.

  • Switch back to Safari from the previous step.
  • Refresh http://localhost:3000.

You should see the BookNotes heading plus six incident rows. The unstyled page already proves that the models and seed data work together.

Dashboard does not render?

  • Check that config/routes.rb points to incidents#index.
  • Check that the view path is app/views/incidents/index.html.erb.
  • Read the first project file named in the server output.

Ask for help with the rendering failure. Help me debug my dashboard request.

Style the dashboard and inspect its queries

The data path works. Styling turns the raw table into a triage screen where severity and status can be scanned quickly.

  • Switch to app/assets/stylesheets/application.css in the Visual Studio Code file sidebar.
  • Replace its contents with the page foundation styles:
:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #172033;
  background: #f3f6fb;
}

body {
  margin: 0;
  background: #f3f6fb;
}

.page-shell {
  width: min(1100px, calc(100% - 2rem));
  margin: 0 auto;
  padding: 3rem 0;
}

.page-header h1 {
  margin: 0.25rem 0;
  font-size: clamp(2rem, 5vw, 3.5rem);
}

.page-header p {
  color: #526078;
}

What does the page foundation do?

These rules establish the dashboard typography plus its background. The page shell keeps the content centered with responsive side spacing.

  • Save app/assets/stylesheets/application.css.
  • Refresh the dashboard in Safari.

You should see a light gray page with a wider centered layout. The dashboard title should appear larger.

Foundation styles missing?

Confirm the stylesheet path is app/assets/stylesheets/application.css. Check that every CSS rule has matching braces.

Ask for help with the stylesheet. Help me debug the page foundation styles.

  • Append the eyebrow plus filter styles beneath the page foundation:
.eyebrow {
  margin: 0;
  color: #4f46e5;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: 2rem 0 1rem;
}

.filter {
  padding: 0.55rem 0.85rem;
  border: 1px solid #cbd5e1;
  border-radius: 999px;
  color: #334155;
  text-decoration: none;
  background: white;
}

How do the links become scannable?

The filter container wraps links when space is tight. Each link becomes a compact pill with a clear clickable boundary.

  • Save app/assets/stylesheets/application.css.
  • Refresh the dashboard in Safari.

You should see the status links arranged as white rounded pills. The BookNotes reliability label should appear in uppercase purple text.

Filter pills still plain?

Check that the view uses filters on the navigation element. Check that each link receives the filter class.

Ask for help matching the selectors. Help me compare my filter classes.

  • Append the active filter plus summary card styles:
.filter.active {
  border-color: #4f46e5;
  color: white;
  background: #4f46e5;
}

.summary-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1rem;
  margin: 1rem 0;
}

.metric,
.table-card {
  border: 1px solid #dbe3ef;
  border-radius: 1rem;
  background: white;
  box-shadow: 0 12px 30px rgba(42, 53, 71, 0.06);
}

.metric {
  display: grid;
  gap: 0.4rem;
  padding: 1rem;
}

What changes when a link is active?

The active filter switches to a purple background. The summary cards share the same border and shadow treatment as the table container.

  • Save app/assets/stylesheets/application.css.
  • Refresh the dashboard in Safari.

You should see the All link highlighted in purple. The three summary values should sit inside separate white cards.

Active link not highlighted?

Confirm the All link receives both filter and active when no status parameter is present. Check the dot between the class names in the selector.

Ask for help with the active class. Help me debug the active status link.

  • Append the metric text plus table layout styles:
.metric span {
  color: #64748b;
  font-size: 0.85rem;
}

.metric strong {
  font-size: 1.75rem;
}

.table-card {
  overflow-x: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th,
td {
  padding: 1rem;
  border-bottom: 1px solid #e8edf5;
  text-align: left;
}

How does the table stay readable?

The table fills its card while keeping every cell aligned to the left. Horizontal overflow protects the layout on narrow screens.

  • Save app/assets/stylesheets/application.css.
  • Refresh the dashboard in Safari.

You should see padded table rows inside a rounded card. The metric values should appear larger than their labels.

Table still crowded?

Check that the table remains inside the table-card element. Confirm the shared cell rule targets both th and td.

Ask for help with the layout selectors. Help me inspect my table spacing.

  • Append the table heading plus urgent severity styles:
th {
  color: #64748b;
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.badge,
.status {
  display: inline-block;
  padding: 0.3rem 0.55rem;
  border-radius: 999px;
  font-size: 0.78rem;
  font-weight: 700;
}

.severity-critical {
  color: #991b1b;
  background: #fee2e2;
}

.severity-high {
  color: #9a3412;
  background: #ffedd5;
}

Why use severity badges?

The shared badge shape separates triage labels from ordinary table text. Critical and high incidents use distinct warm colors so urgent rows stand out.

  • Save app/assets/stylesheets/application.css.
  • Refresh the dashboard in Safari.

You should see uppercase table headings plus colored badges for critical and high incidents.

Urgent badges have no color?

Confirm the view generates a class such as severity-critical from the incident value. Check that the CSS selector uses the same spelling.

Ask for help matching the class names. Help me debug my severity colors.

  • Append the remaining severity plus status styles:
.severity-medium {
  color: #854d0e;
  background: #fef9c3;
}

.severity-low {
  color: #166534;
  background: #dcfce7;
}

.status {
  color: #3730a3;
  background: #e0e7ff;
}

@media (max-width: 700px) {
  .summary-grid {
    grid-template-columns: 1fr;
  }
}

How does the final styling adapt?

Medium and low incidents receive their own severity colors. Status values use a consistent purple badge.

The responsive rule stacks the summary cards on narrow screens. Each metric stays readable.

  • Save app/assets/stylesheets/application.css.
  • Refresh the dashboard in Safari.

You should see colored badges for every severity plus purple status badges. The dashboard is now fully styled.

Some badges still plain?

Compare each severity value with the suffix used by its CSS selector. Confirm that status remains a separate class in the final table cell.

Ask for help with the remaining styles. Help me review all dashboard badges.

Before the final check, do you think one incident query is enough when every row also reads its service name?

  • Refresh http://localhost:3000 in Safari.
  • Select the Open status link.
  • Switch back to the Rails server terminal.

You should see the incident table in Safari. The Open link should be highlighted while the terminal shows repeated service-loading queries as the rows render.

You found the N+1 path

An N+1 query path loads the parent collection first. It performs more queries while traversing an unloaded association for individual rows.

The page looks correct because every service name eventually appears. The log reveals the scaling problem that visual inspection misses.

✔️ Awesome, I've got everything!

Your dashboard is seeded and styled. Its development log now exposes the repeated service lookups that the next step investigates.

ⓧ I'd like to double check the full code

Compare each file below with your saved project. These references match the end of this step.

class CreateServices < ActiveRecord::Migration[8.1]
  def change
    create_table :services do |t|
      t.string :name, null: false

      t.timestamps
    end
  end
end
class CreateIncidents < ActiveRecord::Migration[8.1]
  def change
    create_table :incidents do |t|
      t.references :service, null: false, foreign_key: true
      t.string :title, null: false
      t.string :severity, null: false
      t.string :status, null: false

      t.timestamps
    end
  end
end
class Service < ApplicationRecord
  has_many :incidents, dependent: :destroy

  validates :name, presence: true
end
class Incident < ApplicationRecord
  STATUSES = %w[open investigating resolved].freeze
  SEVERITIES = %w[critical high medium low].freeze

  belongs_to :service

  validates :title, :severity, :status, presence: true
  validates :severity, inclusion: { in: SEVERITIES }
  validates :status, inclusion: { in: STATUSES }
end
catalog = Service.find_or_create_by!(name: "Catalog")
identity = Service.find_or_create_by!(name: "Identity")
media = Service.find_or_create_by!(name: "Media")
notifications = Service.find_or_create_by!(name: "Notifications")

Incident.find_or_create_by!(title: "Login token failures", service: identity) do |incident|
  incident.severity = "critical"
  incident.status = "open"
end

Incident.find_or_create_by!(title: "Cover image processing errors", service: media) do |incident|
  incident.severity = "high"
  incident.status = "open"
end

Incident.find_or_create_by!(title: "Search indexing delay", service: catalog) do |incident|
  incident.severity = "high"
  incident.status = "investigating"
end

Incident.find_or_create_by!(title: "Email digest backlog", service: notifications) do |incident|
  incident.severity = "medium"
  incident.status = "investigating"
end

Incident.find_or_create_by!(title: "Password reset delivery delays", service: notifications) do |incident|
  incident.severity = "medium"
  incident.status = "resolved"
end

Incident.find_or_create_by!(title: "Export latency", service: catalog) do |incident|
  incident.severity = "low"
  incident.status = "resolved"
end
Rails.application.routes.draw do
  root "incidents#index"
end
class IncidentsController < ApplicationController
  def index
    @selected_status = params[:status]
    @incidents = Incident.order(created_at: :desc)
  end
end
<div class="page-shell">
  <header class="page-header">
    <p class="eyebrow">BookNotes reliability</p>
    <h1>Incident Triage Dashboard</h1>
    <p>Review active incidents, affected services, and current response status.</p>
  </header>

  <nav class="filters" aria-label="Filter incidents by status">
    <%= link_to "All", root_path,
      class: "filter#{' active' if @selected_status.nil?}" %>

    <% Incident::STATUSES.each do |status| %>
      <%= link_to status.capitalize, root_path(status: status),
        class: "filter#{' active' if @selected_status == status}" %>
    <% end %>
  </nav>

  <section class="summary-grid" aria-label="Visible incident summary">
    <article class="metric">
      <span>Visible incidents</span>
      <strong><%= @incidents.size %></strong>
    </article>
    <article class="metric">
      <span>Open</span>
      <strong><%= @incidents.count { |incident| incident.status == "open" } %></strong>
    </article>
    <article class="metric">
      <span>Investigating</span>
      <strong><%= @incidents.count { |incident| incident.status == "investigating" } %></strong>
    </article>
  </section>

  <div class="table-card">
    <table>
      <thead>
        <tr>
          <th>Incident</th>
          <th>Service</th>
          <th>Severity</th>
          <th>Status</th>
        </tr>
      </thead>
      <tbody>
        <% @incidents.each do |incident| %>
          <tr>
            <td><%= incident.title %></td>
            <td><%= incident.service.name %></td>
            <td>
              <span class="badge severity-<%= incident.severity %>">
                <%= incident.severity.capitalize %>
              </span>
            </td>
            <td><span class="status"><%= incident.status.capitalize %></span></td>
          </tr>
        <% end %>
      </tbody>
    </table>
  </div>
</div>
:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #172033;
  background: #f3f6fb;
}

body {
  margin: 0;
  background: #f3f6fb;
}

.page-shell {
  width: min(1100px, calc(100% - 2rem));
  margin: 0 auto;
  padding: 3rem 0;
}

.page-header h1 {
  margin: 0.25rem 0;
  font-size: clamp(2rem, 5vw, 3.5rem);
}

.page-header p {
  color: #526078;
}

.eyebrow {
  margin: 0;
  color: #4f46e5;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: 2rem 0 1rem;
}

.filter {
  padding: 0.55rem 0.85rem;
  border: 1px solid #cbd5e1;
  border-radius: 999px;
  color: #334155;
  text-decoration: none;
  background: white;
}

.filter.active {
  border-color: #4f46e5;
  color: white;
  background: #4f46e5;
}

.summary-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1rem;
  margin: 1rem 0;
}

.metric,
.table-card {
  border: 1px solid #dbe3ef;
  border-radius: 1rem;
  background: white;
  box-shadow: 0 12px 30px rgba(42, 53, 71, 0.06);
}

.metric {
  display: grid;
  gap: 0.4rem;
  padding: 1rem;
}

.metric span {
  color: #64748b;
  font-size: 0.85rem;
}

.metric strong {
  font-size: 1.75rem;
}

.table-card {
  overflow-x: auto;
}

table {
  width: 100%;
  border-collapse: collapse;
}

th,
td {
  padding: 1rem;
  border-bottom: 1px solid #e8edf5;
  text-align: left;
}

th {
  color: #64748b;
  font-size: 0.75rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.badge,
.status {
  display: inline-block;
  padding: 0.3rem 0.55rem;
  border-radius: 999px;
  font-size: 0.78rem;
  font-weight: 700;
}

.severity-critical {
  color: #991b1b;
  background: #fee2e2;
}

.severity-high {
  color: #9a3412;
  background: #ffedd5;
}

.severity-medium {
  color: #854d0e;
  background: #fef9c3;
}

.severity-low {
  color: #166534;
  background: #dcfce7;
}

.status {
  color: #3730a3;
  background: #e0e7ff;
}

@media (max-width: 700px) {
  .summary-grid {
    grid-template-columns: 1fr;
  }
}

Your incident dashboard now looks complete while its log exposes the hidden association cost. Next, you will make that lazy-loading path fail loudly so the performance risk becomes reproducible.

Expose the Lazy Loading Failure

The BookNotes dashboard already displays six incidents with their affected services. That visible success hides repeated database work.

Active Record's strict loading makes hidden loading behavior impossible to ignore. In this step, you will use that guard to locate the exact view access responsible.

In this step, get ready to:
  • Replace the incident relation with its strict-loading form.
  • Trigger the lazy service access from the unchanged dashboard.
  • Trace the failure to incident.service.name.
Guard the incident relation

The controller currently returns incidents without loading their services. Adding a strict-loading rule turns the unchanged dashboard into a controlled test of that relation.

  • Switch back to the booknotes window in Visual Studio Code.
  • Select app/controllers/incidents_controller.rb in the Explorer sidebar.
  • Find this line:
@incidents = Incident.order(created_at: :desc)

This relation orders the incidents by creation time. It places no restrictions on later association loading.

  • Replace the current relation with this strict-loading version:
@incidents = Incident.strict_loading.order(created_at: :desc)

What Does Strict Loading Change?

  • strict_loading adds the loading rule to every incident returned by the relation.
  • order(created_at: :desc) preserves the dashboard's existing sort order.
  • The unchanged view now tests whether its service association was loaded deliberately.
  • Save app/controllers/incidents_controller.rb by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Your controller now applies strict loading to the incident relation. Keep the view unchanged for the test.

ⓧ I'd like to double check the full code

Compare your controller with this complete version. Every line should match before you refresh the dashboard.

class IncidentsController < ApplicationController
  def index
    @selected_status = params[:status]
    @incidents = Incident.strict_loading.order(created_at: :desc)
  end
end
Trigger and trace the failure

The dashboard view still requests each service through incident.service.name. Refreshing the page tests that access against the relation's new loading rule.

  • Return to the BookNotes dashboard in Safari.

Before you refresh, do you think strict loading will let the unchanged service lookup complete?

  • Refresh the dashboard at http://localhost:3000.

You will see ActiveRecord::StrictLoadingViolationError instead of the incident table. That failure is the intended evidence that the view attempted an unplanned association query.

Why This Failure Matters

The exception converts repeated service queries into a reproducible failure. The dashboard can no longer hide inefficient association access behind correct HTML.

The stack trace points back to the view that requested the service. You now have a precise path to optimize.

Still Seeing the Dashboard?

  • Confirm app/controllers/incidents_controller.rb contains @incidents = Incident.strict_loading.order(created_at: :desc).
  • Save the controller before refreshing the page again.
  • Confirm the unchanged dashboard view still reads incident.service.name.

Help me diagnose why strict loading is not exposing the lazy service query.

  • Scan the exception page for app/views/incidents/index.html.erb.
  • Return to the booknotes window in Visual Studio Code.
  • Select app/views/incidents/index.html.erb in the Explorer sidebar.
  • Find the association access identified by the stack trace:
<td><%= incident.service.name %></td>

Why Does This Line Fail?

incident.service asks for an association that the controller relation did not preload. Strict loading blocks that lazy database lookup.

The name access reveals the affected service in the table. The next step must preserve that output while loading the association deliberately.

The hidden query path now fails on demand. Next, you will eager-load the services and protect the improvement with query-count tests.

Fix and Prove the N+1 Path

The strict-loading failure from the previous step proved that the dashboard was lazily fetching each service. That visible exception exposed the hidden N+1 query path.

This step turns that diagnosis into a durable Rails fix. You will use eager loading while keeping strict loading active.

Regression tests will protect the query count. A short decision record will capture the reasoning for future reviewers.

In this step, get ready to:
  • Repair the incident query without disabling strict loading.
  • Protect the optimized relation with model and request tests.
  • Capture the implementation in an architecture decision record.
Eager-load services in the controller

The Active Record relation can fetch incidents plus their associated services before the view renders. Strict loading then remains active as a guard against future lazy association access.

  • Select app/controllers/incidents_controller.rb in the Visual Studio Code sidebar.
  • Replace the file contents with the optimized controller below:
class IncidentsController < ApplicationController
  def index
    @selected_status = params[:status]

    scope = Incident
      .includes(:service)
      .strict_loading
      .order(created_at: :desc)

    if Incident::STATUSES.include?(@selected_status)
      scope = scope.where(status: @selected_status)
    end

    @incidents = scope.load
  end
end

What does this controller fix?

  • The includes(:service) call fetches the services needed by the dashboard before the view reads them.
  • The strict_loading call keeps lazy association access visible as an exception.
  • The Incident::STATUSES.include?(@selected_status) check only permits known status values to reach the filter.
  • The scope.load call executes the completed relation once before rendering.
  • Save app/controllers/incidents_controller.rb.
  • Refresh the open dashboard in Safari.

You should see all six seeded incidents in the table again. The server log should no longer show a separate service lookup for every rendered row.

Still seeing the strict-loading exception?

Confirm that includes(:service) appears before strict_loading in the relation. Check that the view still reads incident.service.name.

Make sure Visual Studio Code saved the controller inside the open booknotes application.

Help me diagnose why strict loading still raises after adding eager loading.

Add regression tests

Fixtures give the test suite a small incident dataset with known service relationships. The tests can then measure the optimized query path without depending on development seed data.

  • Add test/fixtures/services.yml in the Visual Studio Code sidebar by copying the content below:
catalog:
  name: Catalog

identity:
  name: Identity

What does this fixture provide?

These records give the tests two named services. Incident fixtures can reference catalog or identity without creating records inside each test.

  • Save test/fixtures/services.yml.

You should see services.yml listed inside test/fixtures.

  • Add test/fixtures/incidents.yml in the Visual Studio Code sidebar by copying the content below:
login_failure:
  title: Login token failures
  severity: critical
  status: open
  service: identity

export_latency:
  title: Export latency
  severity: low
  status: resolved
  service: catalog

What does this incident data cover?

The two incidents cover different services. They also provide distinct severity and status values for validation plus filtering tests.

  • Save test/fixtures/incidents.yml.

You should see incidents.yml listed beside services.yml.

  • Add the model regression test to test/models/incident_test.rb by copying the code below:
require "test_helper"

class IncidentTest < ActiveSupport::TestCase
  test "requires a valid severity" do
    incident = incidents(:login_failure)
    incident.severity = "unknown"

    assert_not incident.valid?
  end

  test "eager loads services without extra queries" do
    loaded_incidents = nil

    assert_queries_count 2 do
      loaded_incidents = Incident.includes(:service).strict_loading.load
    end

    assert_no_queries do
      loaded_incidents.each { |incident| incident.service.name }
    end
  end
end

How does this prove the query fix?

  • The validation test proves that an unknown severity cannot produce a valid incident.
  • The assert_queries_count 2 block permits one incident query plus one service query.
  • The assert_no_queries block proves that reading every service name performs no extra database work.
  • Save test/models/incident_test.rb.
  • Run the test suite from the booknotes terminal with this command:
bin/rails test

What should this test run prove?

The suite should complete with zero failures and zero errors. That result proves the fixtures load plus the two-query assertion holds.

Did the model test fail?

Check the indentation in both fixture files. YAML relationships depend on the exact fixture names identity and catalog.

Compare the tested relation with Incident.includes(:service).strict_loading.load.

Help me debug my failing Rails query-count test.

  • Add the request tests to test/controllers/incidents_controller_test.rb by copying the code below:
require "test_helper"

class IncidentsControllerTest < ActionDispatch::IntegrationTest
  test "renders the incident dashboard" do
    get root_url

    assert_response :success
    assert_dom "h1", "Incident Triage Dashboard"
    assert_dom "tbody tr", 2
  end

  test "filters incidents by an allowlisted status" do
    get root_url(status: "resolved")

    assert_response :success
    assert_dom "tbody tr", 1
    assert_includes response.body, "Export latency"
    assert_not_includes response.body, "Login token failures"
  end
end

What do the request tests protect?

  • The first test proves that the root request renders the dashboard heading plus both fixture rows.
  • The second test sends the allowlisted resolved status to the controller.
  • The response checks prove that the filter keeps the resolved incident plus removes the open incident.
  • Save test/controllers/incidents_controller_test.rb.

Before you run the suite, do you expect the optimized dashboard and status filter to pass together?

  • Run all model and request tests with this command:
bin/rails test

What should the combined suite show?

You should see zero failures and zero errors. The result covers validation, query count, association traversal, dashboard rendering, and status filtering.

Did a request test fail?

Confirm that config/routes.rb still maps the root route to incidents#index.

Check that the fixture names match every reference in the tests.

Help me debug the failing BookNotes request test.

Record the decision and run quality checks

Tests preserve behavior in code. An architecture decision record preserves the context behind that behavior for reviewers who encounter the relation later.

  • Select the existing README.md file in the Visual Studio Code sidebar.
  • Replace its contents with the project documentation below:
# BookNotes Incident Triage Dashboard

A local Rails and PostgreSQL dashboard for reviewing incidents by service, severity, and response status.

## Run locally

1. Run `bin/rails db:prepare`.
2. Run `bin/rails db:seed`.
3. Run `bin/rails server`.
4. Open `http://localhost:3000`.

## Quality checks

Run `bin/rails test`, `bin/rubocop`, and `bin/brakeman` before review.

## Architecture decision: eager-load incident services

Context: The dashboard renders a service name for every incident. Loading incidents without their services creates a lazy association lookup for each row.

Decision: Load incidents with `includes(:service)` and apply `strict_loading` to the relation. Keep request filters allowlisted against model constants before adding hash-based `where` conditions.

Consequences: The dashboard uses one query for incidents and one for services. Any future lazy association access raises during development and tests. The relation remains chainable for status filters.

## Review checklist

- Do model and database constraints agree?
- Are request values checked against an allowlist before filtering?
- Does the query-count test still pass when the table changes?
- Do `bin/rubocop` and `bin/brakeman` complete successfully?
- Does the decision record still describe the implemented query path?

What does the decision record capture?

The record connects the original lazy-loading risk to the chosen includes(:service) plus strict_loading strategy.

The review checklist turns that reasoning into repeatable questions for future changes.

  • Save README.md.

You should now see the local run guide, quality workflow, architecture decision, and review checklist in the README preview.

✔️ Awesome, I've got everything!

Great. Double check that the controller, fixtures, tests, and README are saved before running the final checks.

ⓧ I'd like to double check the full code

Compare each changed file with these complete references.

class IncidentsController < ApplicationController
  def index
    @selected_status = params[:status]

    scope = Incident
      .includes(:service)
      .strict_loading
      .order(created_at: :desc)

    if Incident::STATUSES.include?(@selected_status)
      scope = scope.where(status: @selected_status)
    end

    @incidents = scope.load
  end
end

Controller reference

This version eager-loads services, preserves strict loading, validates the status filter, and loads the completed scope.

catalog:
  name: Catalog

identity:
  name: Identity

Service fixture reference

This fixture file defines the two services referenced by the incident test records.

login_failure:
  title: Login token failures
  severity: critical
  status: open
  service: identity

export_latency:
  title: Export latency
  severity: low
  status: resolved
  service: catalog

Incident fixture reference

This fixture file provides one open incident plus one resolved incident across separate services.

require "test_helper"

class IncidentTest < ActiveSupport::TestCase
  test "requires a valid severity" do
    incident = incidents(:login_failure)
    incident.severity = "unknown"

    assert_not incident.valid?
  end

  test "eager loads services without extra queries" do
    loaded_incidents = nil

    assert_queries_count 2 do
      loaded_incidents = Incident.includes(:service).strict_loading.load
    end

    assert_no_queries do
      loaded_incidents.each { |incident| incident.service.name }
    end
  end
end

Model test reference

This test file protects severity validation plus the two-query eager-loading behavior.

require "test_helper"

class IncidentsControllerTest < ActionDispatch::IntegrationTest
  test "renders the incident dashboard" do
    get root_url

    assert_response :success
    assert_dom "h1", "Incident Triage Dashboard"
    assert_dom "tbody tr", 2
  end

  test "filters incidents by an allowlisted status" do
    get root_url(status: "resolved")

    assert_response :success
    assert_dom "tbody tr", 1
    assert_includes response.body, "Export latency"
    assert_not_includes response.body, "Login token failures"
  end
end

Request test reference

This test file protects the dashboard response plus the allowlisted status filter.

# BookNotes Incident Triage Dashboard

A local Rails and PostgreSQL dashboard for reviewing incidents by service, severity, and response status.

## Run locally

1. Run `bin/rails db:prepare`.
2. Run `bin/rails db:seed`.
3. Run `bin/rails server`.
4. Open `http://localhost:3000`.

## Quality checks

Run `bin/rails test`, `bin/rubocop`, and `bin/brakeman` before review.

## Architecture decision: eager-load incident services

Context: The dashboard renders a service name for every incident. Loading incidents without their services creates a lazy association lookup for each row.

Decision: Load incidents with `includes(:service)` and apply `strict_loading` to the relation. Keep request filters allowlisted against model constants before adding hash-based `where` conditions.

Consequences: The dashboard uses one query for incidents and one for services. Any future lazy association access raises during development and tests. The relation remains chainable for status filters.

## Review checklist

- Do model and database constraints agree?
- Are request values checked against an allowlist before filtering?
- Does the query-count test still pass when the table changes?
- Do `bin/rubocop` and `bin/brakeman` complete successfully?
- Does the decision record still describe the implemented query path?

README reference

This README documents local operation, quality checks, the eager-loading decision, and the review checklist.

  • Switch back to the open BookNotes dashboard in Safari.
  • Refresh the current page.

You should still see the incident table without a strict-loading exception.

Before you run the final suite, do you think every service name can be read while the query count remains fixed at two?

  • Run the complete test suite with this command:
bin/rails test

What does the passing suite prove?

You should see zero failures and zero errors. The two-query assertion plus the no-extra-query assertion now protect the optimized service-loading path.

Is the final suite failing?

Start with the first failing test because later failures can share the same fixture or controller cause.

Compare the failing file with the complete references above.

Help me diagnose my final BookNotes Rails test failure.

  • Check the generated Rails code for style consistency by running:
bin/rubocop

What does the style check confirm?

The style check should complete without unresolved formatting issues. This confirms the edited Ruby files follow the generated application's configured rules.

Did the style check find an issue?

Read the reported file path plus line number. Correct the local formatting issue before running the check again.

Help me resolve the reported Ruby style issue.

  • Scan the application for security warnings by running:
bin/brakeman

What does the security scan confirm?

The scan should complete without application security warnings. The controller uses an allowlisted value before adding the hash-based status condition.

Did the security scan report a warning?

Read the warning category plus the referenced file. Confirm that request values only reach the status condition after the model allowlist check.

Help me interpret the BookNotes security scan warning.

That is the hard part secured. Your dashboard now proves its performance behavior every time the test suite runs.

Secret mission

Add a Severity Filter Without Adding Queries

Your dashboard already filters incidents by status without weakening its optimized query path. In this Secret Mission, you will add an allowlisted severity filter while preserving eager loading and the query-count regression test.

Clean Up Your Resources

Clean Up Your Resources

Every resource in this project lives on your Mac at no ongoing cost. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The local booknotes application built with Rails.
  • The development and test databases for booknotes in PostgreSQL.
  • The local Rails server process that serves the dashboard in Safari.

Keep everything running

No action needed. Choose this if you are still using the dashboard for interview practice or further development.

  • Leave the Rails server running while you continue using the dashboard.
  • Keep the development and test databases in place.
  • Keep the booknotes folder with its source code and documentation.

Pause - I'll come back to this later

Shut down the running server to free up memory. Your databases and project files remain on disk.

  • Switch back to the terminal panel where the Rails server is running.
  • Press Control-C to stop the server.
  • Leave the development and test databases in place.
  • Keep the booknotes folder for your next session.

The dashboard stops responding in Safari. Your incident data and performance tests remain ready for your return.

Delete - I don't want to use this again

Remove the databases and local project files if you want to start fresh. Deletion is permanent for anything that has not been backed up elsewhere.

  • Switch back to the terminal panel where the Rails server is running.
  • Press Control-C to stop the server.
  • Drop the local development and test databases by running this command inside booknotes:
bin/rails db:drop

What does this command remove?

The Rails database task drops the local databases created for the application. Your source files remain in place until you remove the project folder.

The next command permanently removes the local project folder. A separate backup is the only way to recover these files.

  • Move to the folder above the application and remove booknotes by running these commands:
cd ..
rm -rf booknotes

What do these commands remove?

The first line moves the terminal to the folder above the application. The second line permanently removes the entire local application folder.

Your Rails server is stopped. The application files and both local databases have now been removed.

Nice Work!

Nice Work!

You made it. Your BookNotes incident triage dashboard now runs on Ruby on Rails with PostgreSQL.

Here's what you learned:

  • Built an incident triage dashboard that gives engineering leads a filterable queue. Each row displays the affected service, severity, and response status.
  • Diagnosed an Active Record N+1 query by making lazy service loading fail visibly. Fixed the query path with eager loading. Protected the improvement with assertions for exactly two load queries plus zero association queries.
  • Created a local quality workflow covering model behavior, request behavior, code style, and security. Documented the eager-loading tradeoff in an architecture decision record.
  • Completed the optional Secret Mission by adding an allowlisted severity filter. Confirmed that critical incidents render without weakening the eager-loaded query path.

Ready to quiz yourself?