janela 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +23 -7
  4. data/UPGRADING.md +34 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +148 -42
  6. data/app/assets/stylesheets/janela.css +7 -0
  7. data/app/controllers/janela/panes_controller.rb +2 -2
  8. data/app/controllers/janela/queries_controller.rb +11 -0
  9. data/app/controllers/janela/snapshot_queries_controller.rb +1 -0
  10. data/app/helpers/janela/frames_helper.rb +35 -7
  11. data/app/models/janela/pane.rb +26 -2
  12. data/app/models/janela/query.rb +245 -25
  13. data/app/views/janela/frames/show.html.erb +2 -2
  14. data/app/views/janela/panes/_form.html.erb +46 -0
  15. data/app/views/janela/queries/_query.html.erb +18 -5
  16. data/config/locales/en.yml +19 -1
  17. data/db/migrate/20261029000001_add_series_to_janela_panes.rb +9 -0
  18. data/db/migrate/20261029000003_add_ignore_unknown_filters_to_janela_panes.rb +8 -0
  19. data/docs/agents.md +33 -2
  20. data/docs/decisions/018-a-table-is-the-universal-renderer.md +1 -0
  21. data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +1 -0
  22. data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +1 -1
  23. data/docs/decisions/056-the-demo-counts-its-own-visits-and-an-agent-arranges-the-dashboard.md +51 -0
  24. data/docs/decisions/057-a-host-is-told-about-the-agent-tools-and-a-generator-sets-them-up.md +197 -0
  25. data/docs/decisions/058-the-deployed-demo-is-reached-over-ssh-and-its-visits-frame-survives-a-reset.md +94 -0
  26. data/docs/decisions/059-a-host-can-tell-janelas-own-pages-that-its-layout-draws-charts.md +91 -0
  27. data/docs/decisions/060-the-demo-tells-search-engines-what-it-is-and-shows-its-own-adoption.md +79 -0
  28. data/docs/decisions/061-the-demo-speaks-brazilian-portuguese-under-a-prefix-and-the-gem-stays-english.md +118 -0
  29. data/docs/decisions/062-a-time-pane-can-accumulate-and-carry-more-series-on-two-axes.md +282 -0
  30. data/docs/decisions/063-a-pane-can-ignore-a-filter-its-model-cannot-apply.md +147 -0
  31. data/docs/decisions/INDEX.md +23 -15
  32. data/docs/roadmap.md +6 -4
  33. data/lib/generators/janela/mcp/mcp_generator.rb +51 -0
  34. data/lib/generators/janela/mcp/templates/janela-mcp.tt +50 -0
  35. data/lib/janela/definition.rb +38 -2
  36. data/lib/janela/dimension.rb +9 -1
  37. data/lib/janela/doctor.rb +10 -1
  38. data/lib/janela/measure.rb +8 -0
  39. data/lib/janela/tools.rb +33 -2
  40. data/lib/janela/version.rb +1 -1
  41. data/lib/janela.rb +8 -0
  42. metadata +20 -6
@@ -153,6 +153,20 @@ module Janela
153
153
  dimensions.values.filter_map(&:through).map(&:to_s).uniq
154
154
  end
155
155
 
156
+ # The keys of these params Ransack would apply to this model, read by the
157
+ # same helper reject_dropped_filters! uses so the two cannot disagree about
158
+ # which keys are the model's. A pane that ignores unknown filters keeps
159
+ # these, and also a key on a dimension the model declares with a predicate
160
+ # Ransack does not know (path_inn), which is a misuse of a real key and is
161
+ # left for the filter to refuse (ADR 025, ADR 063). Only a key whose
162
+ # attribute the model does not declare at all is dropped.
163
+ def applicable(params)
164
+ return params if params.empty?
165
+
166
+ dropped = unapplied_keys(model.ransack(params.to_h), params)
167
+ params.reject { |key, _| dropped.include?(key) && !misspelt_declared_key?(key) }
168
+ end
169
+
156
170
  # A filter the host fixed for a render (ADR 040), applied as its own
157
171
  # condition before the reader's, so the reader's can only narrow inside
158
172
  # it. The same bounds as any filter: declared dimensions, ADR 025's
@@ -236,14 +250,36 @@ module Janela
236
250
  # Ransack silently discards conditions an allowlist does not permit,
237
251
  # which would quietly return unfiltered numbers.
238
252
  def reject_dropped_filters!(search, params)
239
- applied = search.conditions.flat_map { |condition| condition.attributes.map(&:name) }
240
- dropped = params.keys.reject { |key| applied.any? { |name| key.to_s.start_with?(name) } }
253
+ dropped = unapplied_keys(search, params)
241
254
  return if dropped.empty?
242
255
 
243
256
  raise BadRequest, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
244
257
  "Declare a janela dimension, or add it to ransackable_attributes."
245
258
  end
246
259
 
260
+ # The keys Ransack did not apply: those that are not exactly an attribute
261
+ # it applied followed by a predicate it knows. Exact, so status_code_in
262
+ # is not taken for status because it starts with it.
263
+ def unapplied_keys(search, params)
264
+ applied = search.conditions.flat_map { |condition| condition.attributes.map(&:name) }
265
+ params.keys.reject { |key| applied.any? { |name| predicate_of?(key, name) } }
266
+ end
267
+
268
+ def predicate_of?(key, name)
269
+ prefix = "#{name}_"
270
+ key.to_s.start_with?(prefix) && Ransack.predicates.has_key?(key.to_s.delete_prefix(prefix))
271
+ end
272
+
273
+ # A key Ransack did not apply that is a declared dimension and a suffix
274
+ # that is not another attribute with a known predicate on it: path_inn is
275
+ # path with a typo, status_code_in is a different attribute entirely.
276
+ def misspelt_declared_key?(key)
277
+ dimensions.values.reject(&:through).any? do |dimension|
278
+ rest = key.to_s.delete_prefix("#{dimension.ransack_name}_")
279
+ rest != key.to_s && Ransack.predicates.keys.none? { |predicate| rest.end_with?("_#{predicate}") }
280
+ end
281
+ end
282
+
247
283
  # An allowed attribute still reaches every predicate Ransack knows,
248
284
  # including _matches, an arbitrary LIKE pattern (ADR 025). A dashboard
249
285
  # asks in only the predicates its kind of dimension needs.
@@ -26,11 +26,19 @@ module Janela
26
26
  "hour" => ->(t) { t.strftime("%Y-%m-%d %H:00") },
27
27
  "day" => ->(t) { t.strftime("%Y-%m-%d") },
28
28
  "week" => ->(t) { t.strftime("%Y-%m-%d") },
29
- "month" => ->(t) { t.strftime("%b %Y") },
29
+ "month" => ->(t) { month_label(t) },
30
30
  "quarter" => ->(t) { "Q#{(t.month - 1) / 3 + 1} #{t.year}" },
31
31
  "year" => ->(t) { t.strftime("%Y") }
32
32
  }.freeze
33
33
 
34
+ # The month in the reader's language when the host has that language's month
35
+ # names, and in English when it has not: strftime is English only, and a host
36
+ # that never set a locale must see what it always did (ADR 061).
37
+ def self.month_label(time)
38
+ names = I18n.t("date.abbr_month_names", default: nil)
39
+ names.is_a?(Array) && names[time.month] ? "#{names[time.month]} #{time.year}" : time.strftime("%b %Y")
40
+ end
41
+
34
42
  attr_reader :name, :model, :through, :column, :granularity
35
43
 
36
44
  # A dimension is named for what it means on the dashboard and reads a
data/lib/janela/doctor.rb CHANGED
@@ -38,6 +38,7 @@ module Janela
38
38
  }.freeze
39
39
 
40
40
  SEARCHED = %w[app config lib].freeze
41
+ AGENT_SERVER = "bin/janela-mcp".freeze
41
42
  READABLE = %w[.rb .erb .js .erb.html .html.erb .haml .slim .yml].freeze
42
43
 
43
44
  def initialize(root)
@@ -66,6 +67,14 @@ module Janela
66
67
  # Said out loud every run, because a silence nobody remembers is how a
67
68
  # real finding goes unread.
68
69
  out.puts "Silenced: #{silenced.map(&:code).uniq.join(', ')}." if silenced.any?
70
+
71
+ # Not a finding: it is optional, so it is never counted, silenced or
72
+ # allowed to change the result. It says what it saw, which is a missing
73
+ # file, and is printed on a clean run too (ADR 057).
74
+ unless @root.join(AGENT_SERVER).exist?
75
+ out.puts
76
+ out.puts "Optional: let an agent arrange dashboards. bin/rails generate janela:mcp (docs/agents.md)."
77
+ end
69
78
  findings.none? { |finding| finding.severity == :error }
70
79
  end
71
80
 
@@ -148,7 +157,7 @@ module Janela
148
157
  # the same thing twice.
149
158
  ADDED_COLUMNS = {
150
159
  "Janela::Frame" => %w[key default_model default_where],
151
- "Janela::Pane" => %w[kind heading body link partial height prominence companions value_labels sort],
160
+ "Janela::Pane" => %w[kind heading body link partial height prominence companions value_labels sort cumulative series ignore_unknown_filters],
152
161
  "Janela::Snapshot" => %w[owner_type owner_id]
153
162
  }.freeze
154
163
 
@@ -46,6 +46,14 @@ module Janela
46
46
  aggregate == :ratio
47
47
  end
48
48
 
49
+ # A count or a sum adds up across buckets: a running total of it means
50
+ # something, and a bucket with no rows is zero. An average, a minimum, a
51
+ # maximum or a ratio does not, and a bucket with no rows has no value
52
+ # (ADR 062).
53
+ def additive?
54
+ %i[count sum].include?(aggregate)
55
+ end
56
+
49
57
  def apply(relation)
50
58
  return relation.average(ratio_expression) if ratio?
51
59
 
data/lib/janela/tools.rb CHANGED
@@ -18,10 +18,13 @@ module Janela
18
18
  # The attributes of a pane a caller may set: the ones the README names. The
19
19
  # frame, the position and the timestamps are not an agent's to write.
20
20
  PANE_ATTRIBUTES = %w[kind model measure dimension renderer granularity limit sort span title height prominence
21
- value_labels companions heading body link partial].freeze
21
+ value_labels companions cumulative series ignore_unknown_filters heading body link partial].freeze
22
+ # A frame's grid is the only part of a frame an agent may change (ADR 057).
23
+ # Its name, key and owner are the host's to say (ADR 033, ADR 041).
24
+ FRAME_ATTRIBUTES = %w[columns gap].freeze
22
25
  DIRECTIONS = %w[up down].freeze
23
26
  READS = %w[describe_vocabulary list_frames get_frame read_pane].freeze
24
- WRITES = %w[add_pane update_pane remove_pane move_pane].freeze
27
+ WRITES = %w[add_pane update_pane remove_pane move_pane update_frame].freeze
25
28
 
26
29
  def initialize(scope: nil, write: false)
27
30
  unless scope.respond_to?(:call)
@@ -89,6 +92,12 @@ module Janela
89
92
  reading.merge(values: result.transform_values(&:to_f), formatted: result.transform_values { |value| query.format(value) })
90
93
  end
91
94
 
95
+ def update_frame(frame_id:, **attributes)
96
+ frame = frame_in_scope(frame_id)
97
+ frame.update(frame_attributes(attributes)) || refuse(frame)
98
+ frame_hash(frame)
99
+ end
100
+
92
101
  def add_pane(frame_id:, **attributes)
93
102
  frame = frame_in_scope(frame_id)
94
103
  pane = frame.panes.build(pane_attributes(attributes))
@@ -144,6 +153,13 @@ module Janela
144
153
  attributes.transform_keys(&:to_s)
145
154
  end
146
155
 
156
+ def frame_attributes(attributes)
157
+ unknown = attributes.keys.map(&:to_s) - FRAME_ATTRIBUTES
158
+ raise BadRequest, "not a frame attribute an agent may change: #{unknown.join(', ')}. A frame's #{FRAME_ATTRIBUTES.join(' and ')} can be changed." if unknown.any?
159
+
160
+ attributes.transform_keys(&:to_s)
161
+ end
162
+
147
163
  def refuse(record)
148
164
  raise BadRequest, record.errors.full_messages.to_sentence
149
165
  end
@@ -187,6 +203,11 @@ module Janela
187
203
  "remove_pane" => tool("remove_pane", required: %w[pane_id],
188
204
  description: "Remove a pane and close the gap it leaves.",
189
205
  properties: { pane_id: { type: "integer" } }),
206
+ "update_frame" => tool("update_frame", required: %w[frame_id],
207
+ description: "Change a frame's grid: how many columns it has and the gap between panes. Nothing else of a frame can be changed.",
208
+ properties: { frame_id: { type: "integer" },
209
+ columns: { type: "integer", minimum: Frame::COLUMNS.min, maximum: Frame::COLUMNS.max },
210
+ gap: { type: "integer", minimum: Frame::GAPS.min, maximum: Frame::GAPS.max } }),
190
211
  "move_pane" => tool("move_pane", required: %w[pane_id direction],
191
212
  description: "Move a pane one place up or down its frame.",
192
213
  properties: { pane_id: { type: "integer" }, direction: { type: "string", enum: DIRECTIONS } })
@@ -213,6 +234,16 @@ module Janela
213
234
  prominence: { type: "integer", enum: Pane::PROMINENCES.to_a },
214
235
  value_labels: { type: "boolean", description: "Draw each bar's value on a bar chart; other renderers ignore it" },
215
236
  companions: { type: "array", items: { type: "string" } },
237
+ cumulative: { type: "boolean", description: "A running total of the measure across a time pane's buckets" },
238
+ series: { type: "array", description: "Up to #{Query::MAX_SERIES} more series on a time pane, each a measure over a time dimension of the same model",
239
+ items: { type: "object", required: %w[measure], additionalProperties: false,
240
+ properties: { measure: { type: "string" },
241
+ by: { type: "string", description: "A time dimension; the pane's own when left out" },
242
+ cumulative: { type: "boolean" },
243
+ as: { type: "string", enum: Query::CANVAS },
244
+ axis: { type: "string", enum: Query::AXES },
245
+ title: { type: "string" } } } },
246
+ ignore_unknown_filters: { type: "boolean", description: "Drop the reader's filters this pane's model cannot apply rather than refusing them, for a pane beside panes over another model" },
216
247
  heading: { type: "string" },
217
248
  body: { type: "string" },
218
249
  link: { type: "string", description: "A path on this site, starting with /" },
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.15.0"
4
+ VERSION = "0.16.0"
5
5
  end
data/lib/janela.rb CHANGED
@@ -70,6 +70,14 @@ module Janela
70
70
  # whatever that host's layout says (ADR 023).
71
71
  mattr_accessor :theme, default: nil
72
72
 
73
+ # Whether the engine's own frame page draws a bar or line pane as a chart.
74
+ # False is a table, because the engine's minimal layout loads no chart
75
+ # runtime and cannot know whether a host's layout does (ADR 018). A host that
76
+ # has given Janela's own pages a layout that registers the Stimulus
77
+ # controllers and loads Chart.js says so here (ADR 059). A host that sets it
78
+ # without the runtime gets an empty hole where the table was.
79
+ mattr_accessor :charts_on_own_pages, default: false
80
+
73
81
  # Checks janela:doctor should not report, by the name it prints beside each
74
82
  # finding. A check that is a false alarm for one application stays a false
75
83
  # alarm, and that is a judgement made once at boot, which is what a setting
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: janela
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jay Killeen
@@ -148,6 +148,8 @@ files:
148
148
  - db/migrate/20260930000003_add_companions_to_janela_panes.rb
149
149
  - db/migrate/20261006000001_add_value_labels_to_janela_panes.rb
150
150
  - db/migrate/20261013000001_add_sort_to_janela_panes.rb
151
+ - db/migrate/20261029000001_add_series_to_janela_panes.rb
152
+ - db/migrate/20261029000003_add_ignore_unknown_filters_to_janela_panes.rb
151
153
  - docs/agents.md
152
154
  - docs/composing.md
153
155
  - docs/decisions/001-built-to-be-forked.md
@@ -205,11 +207,21 @@ files:
205
207
  - docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md
206
208
  - docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md
207
209
  - docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md
210
+ - docs/decisions/056-the-demo-counts-its-own-visits-and-an-agent-arranges-the-dashboard.md
211
+ - docs/decisions/057-a-host-is-told-about-the-agent-tools-and-a-generator-sets-them-up.md
212
+ - docs/decisions/058-the-deployed-demo-is-reached-over-ssh-and-its-visits-frame-survives-a-reset.md
213
+ - docs/decisions/059-a-host-can-tell-janelas-own-pages-that-its-layout-draws-charts.md
214
+ - docs/decisions/060-the-demo-tells-search-engines-what-it-is-and-shows-its-own-adoption.md
215
+ - docs/decisions/061-the-demo-speaks-brazilian-portuguese-under-a-prefix-and-the-gem-stays-english.md
216
+ - docs/decisions/062-a-time-pane-can-accumulate-and-carry-more-series-on-two-axes.md
217
+ - docs/decisions/063-a-pane-can-ignore-a-filter-its-model-cannot-apply.md
208
218
  - docs/decisions/INDEX.md
209
219
  - docs/multi-tenancy.md
210
220
  - docs/naming.md
211
221
  - docs/roadmap.md
212
222
  - docs/theming.md
223
+ - lib/generators/janela/mcp/mcp_generator.rb
224
+ - lib/generators/janela/mcp/templates/janela-mcp.tt
213
225
  - lib/janela.rb
214
226
  - lib/janela/definition.rb
215
227
  - lib/janela/dimension.rb
@@ -231,15 +243,17 @@ metadata:
231
243
  bug_tracker_uri: https://github.com/retail-tasker/janela/issues
232
244
  rubygems_mfa_required: 'true'
233
245
  post_install_message: |
234
- Janela 0.15.0: one migration, if you use stored frames.
246
+ Janela 0.16.0: one migration, if you use stored frames.
235
247
 
236
- A category pane can now be sorted by its measure either way, or by its
237
- label (ADR 055), which adds a nullable sort column to janela_panes. Run
248
+ A time pane can now be a running total and carry more series on two
249
+ axes (ADR 062), and a pane can ignore a filter its model cannot apply
250
+ (ADR 063). That adds nullable cumulative, series and
251
+ ignore_unknown_filters columns to janela_panes. Run
238
252
  bin/rails janela:install:migrations and then bin/rails db:migrate. If
239
253
  you never use stored frames there is nothing to do.
240
254
 
241
- Also: groups that tie on the measure now come out in label order, so
242
- a pane with a limit shows the same rows every time.
255
+ Also: a line chart with value_labels now writes its latest value at
256
+ its end.
243
257
 
244
258
  Steps: UPGRADING.md in this gem, or
245
259
  https://github.com/retail-tasker/janela/blob/main/UPGRADING.md