studio-engine 0.104.0 → 0.105.0.rc1

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: 8b96c9bc4046a0dbf2f37602c67e682a23014076b8bb75e3267fa24b039262b9
4
- data.tar.gz: efd37090658d3d72ab2dbda97c3f7bc0c44ceaae606ffad8ba5daa737cb23758
3
+ metadata.gz: b8d6d2e7f0fd6764c079a422a705446c36254603f9c49c2f7999d5ee31ce9ea3
4
+ data.tar.gz: eecf186eda2510ddc315ed69346b1d26de64104f0ee01ef8420bbeddc6d87a75
5
5
  SHA512:
6
- metadata.gz: 5762cb7806278a0d147c2b352a08dea58f4e0b81f68c09dd5a6a94c2734c48350146044aef913518909fd9b20cba19ea56802f0a423ba6efad1fa330b473a3db
7
- data.tar.gz: c1c387fcdf9f31d87e74e5e9d3bb18976afb289fee862841cd316ed67f72d7eea305e0ca1e2ea393ceb7b6f5f11739ca99dbfb5d8318362324f0b98e99d845ac
6
+ metadata.gz: 8f6b2847809ddf9520d659809f9be964314e784c096ab8caa459862635464cb416a5fbd729c3c16eb8b10bda96efafddc92ce0d6ab64c5c338e0c44074a66a1e
7
+ data.tar.gz: d64c2f5daadeafb668d574e32ea8db2ee5f6e50136ce9efd34d62526ee5be505c558933d1e253142bff5d42632bba39dc8337f9ed09c338cbfebfb2f8dc5346f
data/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.105.0 — 2026-10-13
8
+
9
+ ### Added
10
+
11
+ - **`Studio::HealthReportJob`: a daily application health report, off by
12
+ default.** One email at 07:00 in the app's zone listing the last day's
13
+ signups, `ErrorLog` errors grouped by class and message with counts and first
14
+ and last seen, and contact-form submissions when the app names a source; a
15
+ quiet day sends a one-line all-clear. The day runs 07:00 to 07:00 on the
16
+ wall clock, so a daylight-saving change makes it 23 or 25 hours, and a late
17
+ run reports the same day. Nothing sends until a host schedules the job AND
18
+ names a recipient (`config.health_report_to` or `STUDIO_HEALTH_REPORT_TO`);
19
+ QA never sends. New settings: `health_report_to`, `health_report_zone`
20
+ (default `America/Denver`), `health_report_hour` (default `7`),
21
+ `health_report_signups` (default: the host's `User` by `created_at`) and
22
+ `health_report_contacts` (default: none). See `docs/HEALTH_REPORT.md`.
23
+
7
24
  ## 0.104.0 — 2026-10-11
8
25
 
9
26
  ### Added
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # Emails the daily application health report (Studio::HealthReport). The
5
+ # engine schedules nothing: a host turns it on by registering this job at
6
+ # Studio.health_report_hour in Studio.health_report_zone AND naming a
7
+ # recipient. docs/HEALTH_REPORT.md has both snippets.
8
+ #
9
+ # Best-effort, like mcritchie-studio's ContactFlaggedDigestJob: a failure is an
10
+ # ErrorLog receipt (and so a line in tomorrow's report), never a retry storm.
11
+ class HealthReportJob < (defined?(::ApplicationJob) ? ::ApplicationJob : ActiveJob::Base)
12
+ queue_as :default
13
+
14
+ def perform(now = Time.current)
15
+ # QA boots RAILS_ENV=production with a copy of nothing anyone needs to hear about.
16
+ return if Studio.qa_environment?
17
+
18
+ recipients = Studio::HealthReport.recipients
19
+ return if recipients.empty?
20
+
21
+ to = recipients.join(", ")
22
+ window_end = Studio::HealthReport.window(now).end
23
+ Studio::Email.deliver(Studio::HealthReportMailer, :daily, window_end.iso8601, to, to: to)
24
+ rescue StandardError => e
25
+ ErrorLog.capture!(e)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Studio
4
+ # The daily application health report. Namespaced so a host's own mailers
5
+ # cannot shadow it (see Studio::NewsletterMailer).
6
+ #
7
+ # It takes the window's END as an ISO-8601 string, not a built report, so the
8
+ # outbox stores a plain string and a retry re-reads the SAME day.
9
+ class HealthReportMailer < ApplicationMailer
10
+ # The report is an operator memo, not a branded email: its own plain
11
+ # document, whatever mailer layout the host's ApplicationMailer sets.
12
+ layout false
13
+
14
+ def daily(window_end, to)
15
+ finish = Time.iso8601(window_end.to_s)
16
+ @report = Studio::HealthReport.build(window: Studio::HealthReport.window_ending(finish))
17
+ mail(to: to, subject: @report.subject)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,71 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= @report.subject %></title>
7
+ </head>
8
+ <body style="margin:0;padding:24px 12px;background:#f4f4f5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#18181b;">
9
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;background:#ffffff;border:1px solid #e4e4e7;border-radius:8px;">
10
+ <tr>
11
+ <td style="padding:20px 24px;border-bottom:1px solid #e4e4e7;">
12
+ <div style="font-size:18px;font-weight:700;"><%= @report.app_name %> health report</div>
13
+ <div style="font-size:13px;color:#52525b;margin-top:4px;"><%= @report.window_label %></div>
14
+ </td>
15
+ </tr>
16
+ <% if @report.quiet? %>
17
+ <tr>
18
+ <td style="padding:20px 24px;font-size:15px;">
19
+ <strong style="color:#166534;">All clear:</strong>
20
+ no <%= @report.sections.map { |s| s.title.downcase }.to_sentence(two_words_connector: " or ", last_word_connector: ", or ") %> in the last day.
21
+ </td>
22
+ </tr>
23
+ <% else %>
24
+ <% @report.sections.each do |section| %>
25
+ <tr>
26
+ <td style="padding:16px 24px;border-bottom:1px solid #f4f4f5;">
27
+ <div style="font-size:15px;font-weight:600;">
28
+ <%= section.title %>:
29
+ <% if section.failed? %>
30
+ <span style="color:#b91c1c;">could not be read (<%= section.failure %>)</span>
31
+ <% else %>
32
+ <span style="color:<%= section.title == "Errors" && section.count.positive? ? "#b91c1c" : "#18181b" %>;"><%= section.count %></span>
33
+ <% end %>
34
+ </div>
35
+ <% if section.items.any? %>
36
+ <ul style="margin:8px 0 0;padding-left:20px;font-size:14px;color:#3f3f46;overflow-wrap:anywhere;">
37
+ <% section.items.each do |item| %>
38
+ <li style="margin:2px 0;"><%= item %></li>
39
+ <% end %>
40
+ <% if section.more.positive? %>
41
+ <li style="margin:2px 0;color:#71717a;">and <%= section.more %> more</li>
42
+ <% end %>
43
+ </ul>
44
+ <% end %>
45
+ <% if section.title == "Errors" && @report.error_groups.any? %>
46
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:8px;font-size:13px;border-collapse:collapse;">
47
+ <% @report.error_groups.each do |group| %>
48
+ <tr>
49
+ <td style="padding:6px 8px 6px 0;vertical-align:top;font-weight:700;white-space:nowrap;color:#b91c1c;"><%= group.count %>&times;</td>
50
+ <td style="padding:6px 0;vertical-align:top;border-top:1px solid #f4f4f5;">
51
+ <div style="font-family:Menlo,Consolas,monospace;font-weight:600;word-break:break-all;overflow-wrap:anywhere;"><%= group.error_class %></div>
52
+ <div style="color:#3f3f46;word-break:break-word;"><%= group.message.squish.truncate(200) %></div>
53
+ <div style="color:#71717a;font-size:12px;">first <%= @report.stamp(group.first_seen) %> &middot; last <%= @report.stamp(group.last_seen) %></div>
54
+ </td>
55
+ </tr>
56
+ <% end %>
57
+ </table>
58
+ <% if @report.error_kinds > @report.error_groups.size %>
59
+ <div style="font-size:13px;color:#71717a;margin-top:6px;">and <%= @report.error_kinds - @report.error_groups.size %> more kinds</div>
60
+ <% end %>
61
+ <% end %>
62
+ </td>
63
+ </tr>
64
+ <% end %>
65
+ <% end %>
66
+ <tr>
67
+ <td style="padding:12px 24px;font-size:12px;color:#71717a;">Your daily report from McRitchie Studio.</td>
68
+ </tr>
69
+ </table>
70
+ </body>
71
+ </html>
@@ -0,0 +1,26 @@
1
+ <%= @report.app_name %> health report
2
+ <%= @report.window_label %>
3
+
4
+ <% if @report.quiet? -%>
5
+ All clear: no <%= @report.sections.map { |s| s.title.downcase }.to_sentence(two_words_connector: " or ", last_word_connector: ", or ") %> in the last day.
6
+ <% else -%>
7
+ <% @report.sections.each do |section| -%>
8
+ <%= section.title %>: <%= section.failed? ? "could not be read (#{section.failure})" : section.count %>
9
+ <% section.items.each do |item| -%>
10
+ - <%= item %>
11
+ <% end -%>
12
+ <% if section.more.positive? -%>
13
+ ... and <%= section.more %> more
14
+ <% end -%>
15
+ <% if section.title == "Errors" -%>
16
+ <% @report.error_groups.each do |group| -%>
17
+ <%= group.count %>x <%= group.error_class %>: <%= group.message.squish.truncate(160) %>
18
+ first <%= @report.stamp(group.first_seen) %>, last <%= @report.stamp(group.last_seen) %>
19
+ <% end -%>
20
+ <% if @report.error_kinds > @report.error_groups.size -%>
21
+ ... and <%= @report.error_kinds - @report.error_groups.size %> more kinds
22
+ <% end -%>
23
+ <% end -%>
24
+
25
+ <% end -%>
26
+ <% end -%>
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/time"
5
+ require "active_support/core_ext/module/attribute_accessors"
6
+ require "active_support/core_ext/object/blank"
7
+ require "active_support/core_ext/object/try"
8
+ require "active_support/core_ext/string/filters"
9
+
10
+ # The daily application health report: signups, errors and (optionally) contact
11
+ # messages from the last day, emailed at 07:00 in the app's zone. The full
12
+ # adoption guide is docs/HEALTH_REPORT.md.
13
+ #
14
+ # OFF BY DEFAULT, TWICE. Nothing sends until a host both SCHEDULES
15
+ # Studio::HealthReportJob (config/recurring.yml or sidekiq-cron) and NAMES a
16
+ # recipient (config.health_report_to, else ENV["STUDIO_HEALTH_REPORT_TO"]). The
17
+ # engine registers no schedule of its own, and with no recipient the job returns
18
+ # before it reads a row.
19
+ #
20
+ # THE WINDOW IS 07:00 TO 07:00 ON THE APP'S WALL CLOCK, end excluded, copied from
21
+ # mcritchie-studio's ContactFlaggedDigestJob. `24.hours.ago` would drop an hour
22
+ # on the fall-back day (a 25-hour day) and report an hour twice on the
23
+ # spring-forward one. Anchoring on the hour also makes the report a function of
24
+ # the DAY, not the run: a run that lands late, or a retry, reports exactly the
25
+ # window an on-time run would have, and a row at 07:00:00 belongs to one report.
26
+ #
27
+ # The config lives HERE rather than in lib/studio.rb so the unit suite loads it
28
+ # with the rules it governs; `Studio.configure` sets it like any other setting.
29
+ module Studio
30
+ # Who gets the report. A String (comma-separated is fine), an Array, or a
31
+ # callable returning either. nil or blank falls through to
32
+ # ENV["STUDIO_HEALTH_REPORT_TO"]; nobody there either means no email.
33
+ mattr_accessor :health_report_to, default: nil
34
+
35
+ # The wall clock the window is cut on, and the hour that opens it. Schedule
36
+ # the job at this hour in this zone; running it later reports the same day.
37
+ mattr_accessor :health_report_zone, default: "America/Denver"
38
+ mattr_accessor :health_report_hour, default: 7
39
+
40
+ # Where signups come from: a callable taking the window (a Range of times)
41
+ # and returning a relation, an Array, or an Integer count. The default reads
42
+ # the host's `User` by created_at, which is every consumer today; an app whose
43
+ # accounts live elsewhere points this at that model. nil omits the section.
44
+ mattr_accessor :health_report_signups, default: :default
45
+
46
+ # Contact-form submissions, OPTIONAL: nil (the default) omits the section,
47
+ # because the engine has no contact model and each app names its own, e.g.
48
+ # config.health_report_contacts = ->(window) { ContactSubmission.where(created_at: window) }
49
+ mattr_accessor :health_report_contacts, default: nil
50
+
51
+ module HealthReport
52
+ ENV_KEY = "STUDIO_HEALTH_REPORT_TO"
53
+ LISTED = 10 # names shown under a count; the count is always whole
54
+ ERROR_GROUPS = 15 # distinct errors shown; the rest are counted as "more"
55
+
56
+ # One section of the report. `items` is at most LISTED labels; `count` is
57
+ # the true total. `failure` is set when the source raised: the report still
58
+ # sends, and says that this one part could not be read.
59
+ Section = Struct.new(:title, :count, :items, :failure, keyword_init: true) do
60
+ def failed? = !failure.nil?
61
+ # Rows counted but not listed. A count-only section lists none and owes no tail.
62
+ def more = items.empty? ? 0 : [count.to_i - items.size, 0].max
63
+ end
64
+
65
+ # One distinct error: its class, its message, how often, first and last.
66
+ ErrorGroup = Struct.new(:error_class, :message, :count, :first_seen, :last_seen, keyword_init: true)
67
+
68
+ Report = Struct.new(:app_name, :window, :signups, :errors, :error_groups, :error_kinds, :contacts,
69
+ keyword_init: true) do
70
+ def sections = [signups, errors, contacts].compact
71
+
72
+ # Nothing happened and nothing failed: the email is the all-clear line.
73
+ def quiet? = sections.all? { |s| !s.failed? && s.count.to_i.zero? }
74
+
75
+ def subject
76
+ return "#{app_name} health: all clear" if quiet?
77
+
78
+ parts = sections.reject { |s| s.count.to_i.zero? && !s.failed? }.map do |s|
79
+ s.failed? ? "#{s.title.downcase} unavailable" : HealthReport.pluralize(s.count, s.title.downcase.chomp("s"))
80
+ end
81
+ "#{app_name} health: #{parts.join(', ')}"
82
+ end
83
+
84
+ # A time on the report's own clock: "Oct 13, 6:42 AM".
85
+ def stamp(time) = time ? time.in_time_zone(window.end.time_zone).strftime("%b %-d, %-l:%M %p") : "unknown"
86
+
87
+ # "Oct 12, 7:00 AM to Oct 13, 7:00 AM MDT"
88
+ def window_label
89
+ from, to = window.begin, window.end
90
+ "#{from.strftime('%b %-d, %-l:%M %p')} to #{to.strftime('%b %-d, %-l:%M %p %Z')}"
91
+ end
92
+ end
93
+
94
+ module_function
95
+
96
+ def window(now = Time.now, zone: Studio.health_report_zone, hour: Studio.health_report_hour)
97
+ finish = now.in_time_zone(zone).change(hour: hour)
98
+ finish -= 1.day if finish > now
99
+ (finish - 1.day)...finish
100
+ end
101
+
102
+ # The window that ENDS at `finish` (the job passes this through the outbox,
103
+ # so a retry re-reads the same day rather than the day it happens to run on).
104
+ def window_ending(finish, zone: Studio.health_report_zone)
105
+ finish = finish.in_time_zone(zone)
106
+ (finish - 1.day)...finish
107
+ end
108
+
109
+ def recipients
110
+ configured = Studio.health_report_to
111
+ configured = configured.call if configured.respond_to?(:call)
112
+ list = split(configured)
113
+ list = split(ENV.fetch(ENV_KEY, nil)) if list.empty?
114
+ list
115
+ end
116
+
117
+ def split(value)
118
+ Array(value).flat_map { |v| v.to_s.split(",") }.map(&:strip).reject(&:empty?)
119
+ end
120
+
121
+ # ErrorLog.capture! stores "#<Class: message>"; a row written by hand may
122
+ # carry only a message. Returns [class, message].
123
+ def error_name(text)
124
+ return ["Error", "(no message)"] if text.blank?
125
+
126
+ match = /\A#<([A-Z][\w:]*): (.*)>\z/m.match(text.to_s)
127
+ match ? [match[1], match[2]] : ["Error", text.to_s]
128
+ end
129
+
130
+ def pluralize(count, noun)
131
+ "#{count} #{count == 1 ? noun : "#{noun}s"}"
132
+ end
133
+
134
+ # ---- gathering (needs the host's database) ------------------------------
135
+
136
+ def build(window: self.window(Time.now))
137
+ Report.new(
138
+ app_name: Studio.app_name,
139
+ window: window,
140
+ signups: section("Signups", Studio.health_report_signups, window),
141
+ contacts: section("Contact messages", Studio.health_report_contacts, window),
142
+ **errors(window)
143
+ )
144
+ end
145
+
146
+ def section(title, source, window)
147
+ source = default_signups_source if source == :default
148
+ return nil if source.nil?
149
+
150
+ collect(title, source.call(window))
151
+ rescue StandardError => e
152
+ capture(e)
153
+ Section.new(title: title, count: 0, items: [], failure: e.class.name)
154
+ end
155
+
156
+ # The host's `User`, newest first, when it has a created_at; otherwise no
157
+ # section at all, rather than a "0 signups" line that is not true.
158
+ def default_signups_source
159
+ return nil unless defined?(::User) && ::User.respond_to?(:column_names) && ::User.column_names.include?("created_at")
160
+
161
+ ->(window) { ::User.where(created_at: window).order(created_at: :desc) }
162
+ end
163
+
164
+ def collect(title, result)
165
+ case result
166
+ when nil then Section.new(title: title, count: 0, items: [])
167
+ when Integer then Section.new(title: title, count: result, items: [])
168
+ else
169
+ count = result.respond_to?(:count) ? result.count : result.size
170
+ rows = result.respond_to?(:limit) ? result.limit(LISTED).to_a : Array(result).first(LISTED)
171
+ Section.new(title: title, count: count, items: rows.map { |r| label(r) })
172
+ end
173
+ end
174
+
175
+ # A short, human name for a row: its own health_report_label if it has
176
+ # one, else a name, then an email, then "Model #id".
177
+ def label(record)
178
+ return record.to_s if record.is_a?(String)
179
+ return record.health_report_label.to_s if record.respond_to?(:health_report_label)
180
+
181
+ name = %i[display_name name].lazy.filter_map { |m| record.public_send(m).presence if record.respond_to?(m) }.first
182
+ email = record.public_send(:email).presence if record.respond_to?(:email)
183
+ return "#{name} <#{email}>" if name && email && name != email
184
+ return (name || email).to_s if name || email
185
+
186
+ "#{record.class.name} ##{record.try(:id)}"
187
+ rescue StandardError
188
+ "#{record.class.name} ##{record.try(:id)}"
189
+ end
190
+
191
+ # Grouped in SQL: a bad day can hold thousands of rows. The key is the
192
+ # stored "#<Class: message>", which is the class AND the message.
193
+ def errors(window)
194
+ empty = { errors: Section.new(title: "Errors", count: 0, items: []), error_groups: [], error_kinds: 0 }
195
+ return empty unless defined?(::ErrorLog) && ::ErrorLog.table_exists?
196
+
197
+ key = "COALESCE(#{::ErrorLog.connection.quote_column_name('inspect')}, message)"
198
+ scope = ::ErrorLog.where(created_at: window)
199
+ total = scope.count
200
+ return empty if total.zero?
201
+
202
+ kinds = scope.distinct.count(Arel.sql(key))
203
+ created = ::ErrorLog.type_for_attribute("created_at")
204
+ groups = scope.group(Arel.sql(key)).order(Arel.sql("COUNT(*) DESC, MAX(created_at) DESC")).limit(ERROR_GROUPS)
205
+ .pluck(Arel.sql(key), Arel.sql("COUNT(*)"), Arel.sql("MIN(created_at)"), Arel.sql("MAX(created_at)"))
206
+ .map do |text, count, first, last|
207
+ klass, message = error_name(text)
208
+ ErrorGroup.new(error_class: klass, message: message.to_s.truncate(300), count: count.to_i,
209
+ first_seen: created.deserialize(first), last_seen: created.deserialize(last))
210
+ end
211
+ { errors: Section.new(title: "Errors", count: total, items: []), error_groups: groups, error_kinds: kinds }
212
+ rescue StandardError => e
213
+ capture(e)
214
+ { errors: Section.new(title: "Errors", count: 0, items: [], failure: e.class.name), error_groups: [], error_kinds: 0 }
215
+ end
216
+
217
+ def capture(error)
218
+ ::ErrorLog.capture!(error) if defined?(::ErrorLog)
219
+ rescue StandardError
220
+ nil
221
+ end
222
+ end
223
+ end
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.104.0"
2
+ VERSION = "0.105.0.rc1"
3
3
  end
data/lib/studio.rb CHANGED
@@ -38,6 +38,7 @@ require "studio/profile_sections"
38
38
  require "studio/profile_image"
39
39
  require "studio/oauth_identity"
40
40
  require "studio/newsletter"
41
+ require "studio/health_report"
41
42
  require "studio/username_generator"
42
43
  require "studio/public_user"
43
44
  require "studio/name_parts"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.104.0
4
+ version: 0.105.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-11 00:00:00.000000000 Z
11
+ date: 2026-10-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -474,7 +474,9 @@ files:
474
474
  - app/javascript/studio/vendor/stimulus.js
475
475
  - app/jobs/error_log_cleanup_job.rb
476
476
  - app/jobs/studio/email_delivery_job.rb
477
+ - app/jobs/studio/health_report_job.rb
477
478
  - app/mailers/application_mailer.rb
479
+ - app/mailers/studio/health_report_mailer.rb
478
480
  - app/mailers/studio/newsletter_mailer.rb
479
481
  - app/mailers/studio/profile_mailer.rb
480
482
  - app/mailers/user_mailer.rb
@@ -574,6 +576,8 @@ files:
574
576
  - app/views/studio/emails/show.html.erb
575
577
  - app/views/studio/fields/_date_of_birth.html.erb
576
578
  - app/views/studio/geo_settings/edit.html.erb
579
+ - app/views/studio/health_report_mailer/daily.html.erb
580
+ - app/views/studio/health_report_mailer/daily.text.erb
577
581
  - app/views/studio/knowledge_docs/_access_chips.html.erb
578
582
  - app/views/studio/knowledge_docs/_browser.html.erb
579
583
  - app/views/studio/knowledge_docs/_chip_styles.html.erb
@@ -733,6 +737,7 @@ files:
733
737
  - lib/studio/geo.rb
734
738
  - lib/studio/geo/countries.rb
735
739
  - lib/studio/geo/lookup.rb
740
+ - lib/studio/health_report.rb
736
741
  - lib/studio/hold_button.rb
737
742
  - lib/studio/host_schema.rb
738
743
  - lib/studio/image_cache.rb