Skip to content
Merged
46 changes: 46 additions & 0 deletions app/assets/stylesheets/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,49 @@ table.reports td.diff div {
text-overflow: ellipsis;
width: 100px;
}

/* Search form */

form.search-form {
max-width: 55em;
}

form.search-form .search-row {
display: flex;
align-items: center;
gap: 0.5em;
margin: 0.5em 0;
}

form.search-form .search-row label {
display: flex;
align-items: center;
gap: 0.25em;
}

form.search-form .search-text-row label {
flex: 1;
}

form.search-form .search-text-row input[type="text"] {
flex: 1;
}

form.search-form .search-filter-row {
justify-content: space-between;
}

/* Search results reuse table.reports, but their columns are in a different
order, so the reports failure-column rule lands on Match and Diff. */
table.search-results td.match,
table.search-results td.diff {
background-color: transparent;
}

table.search-results td.match code {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
width: 320px;
}
53 changes: 53 additions & 0 deletions app/controllers/search_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
class SearchController < ApplicationController
PER_PAGE = 50
MAX_PAGE = 200

# GET /search
# Searches log excerpts of failed builds. Reports are narrowed by the
# indexed columns (server_id, branch, datetime, revision) first, then
# matched against log_excerpts.content (trigram GIN index on PostgreSQL).
def index
@q = params[:q].to_s.strip
@server_id = params[:server].to_s[/\A\d+\z/]
@branch = params[:branch].to_s.strip
@revision = params[:revision].to_s.strip
@from = parse_time(params[:from])
@to = parse_time(params[:to], end_of_day: true)
# to_s first: params[:page] is an Array for ?page[]=1, which has no to_i.
# Clamping also keeps OFFSET within range for absurd page numbers.
@page = params[:page].to_s.to_i.clamp(1, MAX_PAGE)
@servers = Server.order(:ordinal).to_a

@searched = @q.present? || @revision.present? || @branch.present? ||
@server_id.present? || @from || @to
unless @searched
@reports = []
return
end

reports = Report.joins(:log_excerpt).includes(:server, :log_excerpt).
order("reports.datetime DESC")
reports = reports.where(server_id: @server_id) if @server_id
reports = reports.where(branch: @branch) if @branch.present?
reports = reports.where("reports.datetime >= ?", @from) if @from
reports = reports.where("reports.datetime <= ?", @to) if @to
if @revision.present?
reports = reports.where("reports.revision LIKE ?", "#{Report.sanitize_sql_like(@revision)}%")
end
reports = reports.merge(LogExcerpt.content_match(@q)) if @q.present?

page = reports.limit(PER_PAGE + 1).offset((@page - 1) * PER_PAGE).to_a
@has_next = page.size > PER_PAGE
@reports = page.first(PER_PAGE)
end

private

def parse_time(str, end_of_day: false)
return nil if str.blank?
date = Date.parse(str) rescue nil
return nil unless date
time = Time.utc(date.year, date.month, date.day)
end_of_day ? time + 86399 : time
end
end
74 changes: 74 additions & 0 deletions app/models/log_excerpt.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
require 'net/http'
require 'zlib'
require 'stringio'

class LogExcerpt < ApplicationRecord
belongs_to :report

MAX_CONTENT_BYTES = 100_000

# Fetch fail.txt and diff.txt from S3 for the report and store them as a
# searchable excerpt. Creates an empty-content row when nothing is available
# (missing or empty objects) so that backfill can resume by max report_id.
# Network errors are raised to the caller.
def self.capture(report)
return nil unless %r{\Ahttps?://rubyci\.s3\.amazonaws\.com/}.match?(report.server&.uri.to_s)
content = fetch_content(report)
excerpt = find_or_initialize_by(report_id: report.id)
excerpt.content = content
excerpt.save!
excerpt
end

def self.fetch_content(report)
sections = [report.failtxt_uri, report.difftxt_uri].filter_map do |uri|
body = fetch_text(uri)
body unless body.nil? || body.empty?
end
return "" if sections.empty?
# Budget each section separately, otherwise a fail.txt over the limit
# crowds the diff out entirely. A section under budget donates its
# remainder to the other one. The separators come out of the budget so
# the joined result still fits in MAX_CONTENT_BYTES.
budget = (MAX_CONTENT_BYTES - (sections.size - 1)) / sections.size
slack = sections.sum { |s| [budget - s.bytesize, 0].max }
sections.map { |s| truncate_bytes(s, budget + slack) }.join("\n")
end

# GET a gzipped text file. Returns nil on 404. S3 serves these either with
# Content-Encoding: gzip (Net::HTTP inflates the body) or as raw gzip bytes.
def self.fetch_text(uri)
uri = URI(uri)
res = Net::HTTP.start(uri.host, uri.port, open_timeout: 10, read_timeout: 30, use_ssl: uri.scheme == "https") do |h|
h.get(uri.path)
end
return nil if res.code == "404"
res.value
body = res.body
begin
body = Zlib::GzipReader.new(StringIO.new(body)).read
rescue Zlib::Error
end
body.force_encoding(Encoding::UTF_8).scrub("?").tr("\0", "")
end

def self.truncate_bytes(str, max)
return str if str.bytesize <= max
str.byteslice(0, max).scrub("")
end

# Substring match on content. On PostgreSQL the trigram GIN index makes
# ILIKE efficient; SQLite (development/test) falls back to plain LIKE.
def self.content_match(query)
operator = defined?(SQLite3) ? "LIKE" : "ILIKE"
# explicit ESCAPE: SQLite's LIKE has no default escape character
where("log_excerpts.content #{operator} ? ESCAPE '\\'", "%#{sanitize_sql_like(query)}%")
end

def matching_line(query)
return nil if query.blank?
q = query.downcase
line = content.each_line.find { |l| l.downcase.include?(q) }
line&.strip&.truncate(200)
end
end
25 changes: 24 additions & 1 deletion app/models/report.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

class Report < ApplicationRecord
belongs_to :server
has_one :log_excerpt, dependent: :delete
validates :server_id, :presence => true
# SVN revision number or full 40-hex git commit SHA
validates :revision, :format => { :with => /\A(?:\d+|\h{40})\z/ }, allow_nil: true
Expand Down Expand Up @@ -130,6 +131,21 @@ def recenturi
server.recent_uri(branch_opts)
end

def failtxt_uri
s3txt_uri('compressed_failhtml_relpath', 'fail')
end

def difftxt_uri
s3txt_uri('compressed_diffhtml_relpath', 'diff')
end

def s3txt_uri(key, kind)
relpath = meta&.[](key)&.sub('.html.gz', '.txt.gz') ||
datetime.strftime("log/%Y%m%dT%H%M%SZ.#{kind}.txt.gz")
"#{server.uri.chomp('/')}/#{depsuffixed_name}/#{relpath}"
end
private :s3txt_uri

def meta
if defined?(@meta)
@meta
Expand Down Expand Up @@ -174,7 +190,7 @@ def self.scan_recent_ltsv(server, depsuffixed_name, body)
diff = h["different_sections"]
summary << (diff ? " (diff:#{diff})" : " (no diff)")

Report.create!(
report = Report.create!(
server_id: server.id,
datetime: datetime,
branch: branch,
Expand All @@ -183,6 +199,13 @@ def self.scan_recent_ltsv(server, depsuffixed_name, body)
ltsv: line,
summary: summary.gsub(/<[^>]*>/, ''),
)
if h["result"] != "success"
begin
LogExcerpt.capture(report)
rescue => e
warn [e, server.uri, "failed to capture log excerpt", report.id].inspect
end
end
end
rescue RuntimeError => e # It seems not a chkbuild log
warn [e, server.uri, path, "failed to scan_reports"].inspect
Expand Down
1 change: 1 addition & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<ul class="navbar-nav">
<li class="nav-item"><%= link_to "Current Reports", "/reports", class: "nav-link" %></li>
<li class="nav-item"><%= link_to "Latest Reports", "/", class: "nav-link" %></li>
<li class="nav-item"><%= link_to "Search", "/search", class: "nav-link" %></li>
</ul>
</div>
</nav>
Expand Down
76 changes: 76 additions & 0 deletions app/views/search/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<% content_for(:title) { "Search failures - RubyCI" } %>

<h3>Search failure logs</h3>

<%= form_with url: search_path, method: :get, local: true, html: { class: "search-form" } do %>
<p class="search-row search-text-row">
<label>Text: <%= text_field_tag :q, @q, placeholder: "test name, exception class, error message ..." %></label>
<%# The revision query parameter is kept for machine clients (planned MCP
server) but intentionally has no visible form field. %>
<%= hidden_field_tag :revision, @revision if @revision.present? %>
<%= submit_tag "Search" %>
</p>
<p class="search-row search-filter-row">
<label>Server: <%= select_tag :server, options_for_select(@servers.map {|s| [s.name, s.id] }, @server_id), include_blank: "(all)" %></label>
<label>Branch: <%= text_field_tag :branch, @branch, size: 10, placeholder: "master" %></label>
<label>From: <%= date_field_tag :from, params[:from] %></label>
<label>To: <%= date_field_tag :to, params[:to] %></label>
</p>
<% end %>

<% if @searched %>
<% if @reports.empty? %>
<p>No matching failure logs found<%= " on page #{@page}" if @page > 1 %>.</p>
<% else %>
<div class="table-responsive table-responsive-border">
<table class="reports search-results table table-striped table-bordered">
<thead>
<tr>
<th>Server</th>
<th>Datetime</th>
<th>Branch</th>
<th>Option</th>
<th>Revision</th>
<th>Summary</th>
<th>Match</th>
<th>Diff</th>
</tr>
</thead>
<tbody>
<% @reports.each do |report| %>
<%
failuri = report.failuri || report.loguri
if report.revision&.match?(/\A\d+\z/)
revision = "r#{report.revision}"
elsif report.sha1
revision = report.sha1[0, 11]
end
revision_link = link_to revision, report.revisionuri if revision
snippet = report.log_excerpt&.matching_line(@q)
%>
<tr>
<td class="server"><%= report.server.name %></td>
<td class="datetime"><%= link_to report.sjstdt, report.loguri, title: report.jstdt %></td>
<td class="branch"><%= report.branch %></td>
<td class="option"><%= report.option %></td>
<td class="revision"><%= revision_link %></td>
<td class="summary failure"><div><%= link_to report.shortsummary, failuri, title: report.shortsummary %></div></td>
<td class="match"><code title="<%= snippet %>"><%= snippet %></code></td>
<td class="diff"><div><%= link_to report.diffstat, report.diffuri, title: report.diffstat %></div></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<p>
<% if @page > 1 %>
<%= link_to "Prev", search_path(request.query_parameters.merge("page" => @page - 1)) %>
<% end %>
<% if @has_next %>
<%= link_to "Next", search_path(request.query_parameters.merge("page" => @page + 1)) %>
<% end %>
</p>
<% end %>
<% else %>
<p>Search the excerpted fail/diff logs of failed builds. Datetimes are UTC.</p>
<% end %>
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Rails.application.routes.draw do
root :to => 'reports#current'

get "search" => "search#index"

resources :reports, only: [:show, :index] do
collection do
get "current"
Expand Down
18 changes: 18 additions & 0 deletions db/migrate/20260728023000_create_log_excerpts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class CreateLogExcerpts < ActiveRecord::Migration[8.1]
def change
create_table :log_excerpts do |t|
t.integer :report_id, null: false
t.text :content, null: false
t.timestamps
t.index [:report_id], unique: true
end
add_foreign_key :log_excerpts, :reports

# pg_trgm and GIN index are PostgreSQL-only; development and test run on SQLite
if connection.adapter_name == "PostgreSQL"
enable_extension "pg_trgm"
add_index :log_excerpts, :content, using: :gin, opclass: :gin_trgm_ops,
name: "index_log_excerpts_on_content_trgm"
end
end
end
15 changes: 13 additions & 2 deletions db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[8.1].define(version: 2026_07_27_095902) do
ActiveRecord::Schema[8.1].define(version: 2026_07_28_023000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
enable_extension "pg_trgm"

create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
Expand Down Expand Up @@ -42,6 +43,15 @@
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end

create_table "log_excerpts", force: :cascade do |t|
t.text "content", null: false
t.datetime "created_at", null: false
t.integer "report_id", null: false
t.datetime "updated_at", null: false
t.index ["content"], name: "index_log_excerpts_on_content_trgm", opclass: :gin_trgm_ops, using: :gin
t.index ["report_id"], name: "index_log_excerpts_on_report_id", unique: true
end

create_table "recents", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "etag", null: false
Expand All @@ -63,7 +73,7 @@
t.datetime "updated_at", precision: nil
t.index ["branch"], name: "index_reports_on_branch"
t.index ["datetime"], name: "index_reports_on_datetime"
t.index ["revision"], name: "index_reports_on_revision", opclass: :varchar_pattern_ops
t.index ["revision"], name: "index_reports_on_revision"
t.index ["server_id", "branch", "option"], name: "index_reports_on_server_id_and_branch_and_option"
end

Expand All @@ -79,5 +89,6 @@

add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "log_excerpts", "reports"
add_foreign_key "recents", "servers"
end
Loading
Loading