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
@@ -49,6 +49,8 @@ module Janela
49
49
  normalizes :sort, with: ->(value) { value.presence }
50
50
  before_validation :normalise_companions
51
51
  validate :companions_are_declared
52
+ before_validation :normalise_series
53
+ validate :series_are_declared
52
54
  validates :measure, presence: true, if: :query?
53
55
  validate :declared_by_a_janela_block, if: :query?
54
56
  validate :holds_no_query, unless: :query?
@@ -91,7 +93,8 @@ module Janela
91
93
  # instead (ADR 018).
92
94
  def query(filters: {}, fixed: {}, renderer: self.renderer)
93
95
  Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
94
- renderer: renderer, granularity: granularity, limit: limit, sort: sort, height: height, prominence: prominence, value_labels: value_labels, companions: companions, filters: filters, fixed: fixed,
96
+ renderer: renderer, granularity: granularity, limit: limit, sort: sort, height: height, prominence: prominence, value_labels: value_labels, companions: companions,
97
+ cumulative: cumulative, series: series, ignore_unknown_filters: ignore_unknown_filters, filters: filters, fixed: fixed,
95
98
  default: frame.default_for(definition.model), title: title)
96
99
  end
97
100
 
@@ -145,6 +148,27 @@ module Janela
145
148
  nil # an unknown model is reported by its own validation
146
149
  end
147
150
 
151
+ # The form sends a blank slot beyond the series it holds, and a series
152
+ # with no measure is no series. An empty list is nothing rather than [].
153
+ def normalise_series
154
+ self.series = Array(series).map { |each| each.to_h.stringify_keys.transform_values(&:to_s).reject { |_, value| value.blank? } }
155
+ .reject { |each| each["measure"].blank? }.presence
156
+ end
157
+
158
+ # The same rules a URL is held to, from the same place, so a row cannot
159
+ # name what a request could not (ADR 062). A running total on a pane
160
+ # that is not over time is refused the same way, by the query.
161
+ def series_are_declared
162
+ return if (series.blank? && !cumulative) || !query? || measure.blank? || Query::RENDERERS.exclude?(renderer.to_s)
163
+
164
+ Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
165
+ renderer: renderer, cumulative: cumulative, series: series)
166
+ rescue Janela::BadRequest => error
167
+ errors.add(error.message.start_with?("cumulative") ? :cumulative : :series, error.message)
168
+ rescue Janela::Error
169
+ nil # an unknown model or dimension is reported by its own validation
170
+ end
171
+
148
172
  def panes_above
149
173
  frame.panes.where(position: ...position).order(:position)
150
174
  end
@@ -175,7 +199,7 @@ module Janela
175
199
  # A row is one thing or the other, so the database never holds half a
176
200
  # query that nothing will run.
177
201
  def holds_no_query
178
- %i[model measure dimension granularity].each do |column|
202
+ %i[model measure dimension granularity cumulative series].each do |column|
179
203
  errors.add(column, "belongs to a query pane, not a #{kind} one") if self[column].present?
180
204
  end
181
205
  end
@@ -33,18 +33,64 @@ module Janela
33
33
  # dimension's shared fact, by label.
34
34
  Companion = Struct.new(:name, :header, :fact, :cells, keyword_init: true)
35
35
 
36
- attr_reader :definition, :measure, :dimension, :renderer, :limit, :sort, :height, :prominence, :value_labels, :companions, :filters, :fixed, :default, :snapshot
36
+ # A time pane may carry up to three more series, each a measure over a
37
+ # time dimension of the same model, drawn as a line or bars on the left
38
+ # axis or a right one (ADR 062). Bounded as companions are, since each is
39
+ # a query. The keys are the words janela_pane takes, so a series reads the
40
+ # same in a row, in the helper and in the URL.
41
+ MAX_SERIES = 3
42
+ AXES = %w[left right].freeze
43
+ SERIES_KEYS = %w[measure by cumulative as axis title].freeze
44
+ # A series is joined to the pane's buckets one step at a time, so a column
45
+ # with a stray year 1900 would otherwise build a hundred thousand of them.
46
+ MAX_BUCKETS = 10_000
47
+ Series = Struct.new(:measure, :dimension, :cumulative, :renderer, :axis, :title, :values, keyword_init: true)
48
+
49
+ attr_reader :definition, :measure, :dimension, :renderer, :limit, :sort, :height, :prominence, :value_labels, :companions, :cumulative, :series, :ignore_unknown_filters, :filters, :fixed, :default, :snapshot
37
50
 
38
51
  # The helper renders the turbo frame and the controller renders its
39
52
  # replacement, so both derive the id the same way from the same parameters.
40
- def self.turbo_frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, snapshot: nil)
53
+ # A measure to date and the same measure per period are two answers that
54
+ # may share a page, so a set cumulative is part of the id (ADR 062, as a
55
+ # sort is in ADR 055). Two panes that differ only in their series are two
56
+ # frames too, and a series is a hash, so it joins the id as a short digest
57
+ # of what was given.
58
+ def self.turbo_frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, cumulative: nil, series: nil, snapshot: nil)
41
59
  parts = [ "janela", ("snapshot_#{snapshot.id}" if snapshot), model.model_name.route_key, measure, by,
42
60
  (granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit),
43
- (sort if by && sort.present? && sort.to_s != "desc") ]
61
+ (sort if by && sort.present? && sort.to_s != "desc"), ("cumulative" if by && flag!("cumulative", cumulative)),
62
+ (series_digest(series) if by) ]
44
63
  parts.compact.join("_")
45
64
  end
46
65
 
47
- def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
66
+ # The same words in any spelling a host, a URL or a row gives them: symbol
67
+ # or string keys, true or "1", a blank left out. Nil when there are none,
68
+ # so a pane without series keeps the id it always had.
69
+ def self.series_digest(series)
70
+ given = Array(series).filter_map do |each|
71
+ next unless each.respond_to?(:to_h)
72
+
73
+ words = each.to_h.to_h { |key, value| [ key.to_s, value.to_s ] }.reject { |_, value| value.blank? }
74
+ words["cumulative"] = "1" if words.key?("cumulative") && flag!("cumulative", words["cumulative"])
75
+ words.delete("cumulative") unless words["cumulative"] == "1"
76
+ words.sort.to_h
77
+ end
78
+ return if given.empty?
79
+
80
+ "s#{Digest::SHA1.hexdigest(given.to_json)[0, 8]}"
81
+ end
82
+
83
+ # A yes or a no from a URL or a form, with unset the same as no. Anything
84
+ # else is refused rather than read as one of them.
85
+ def self.flag!(name, value)
86
+ case value.to_s
87
+ when "", "0", "false" then false
88
+ when "1", "true" then true
89
+ else raise BadRequest, "#{name} must be 1 or 0, got #{value.inspect}"
90
+ end
91
+ end
92
+
93
+ def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, cumulative: nil, series: nil, ignore_unknown_filters: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
48
94
  @definition = definition
49
95
  @title = title
50
96
  @measure = measure
@@ -64,8 +110,11 @@ module Janela
64
110
  @sort = chosen unless chosen == "desc"
65
111
  @height = height!(height) if height.present?
66
112
  @prominence = prominence!(prominence) if prominence.present?
67
- @value_labels = value_labels!(value_labels)
113
+ @value_labels = flag!("value_labels", value_labels)
114
+ @ignore_unknown_filters = flag!("ignore_unknown_filters", ignore_unknown_filters)
68
115
  @companions = companions!(companions)
116
+ @cumulative = cumulative!(cumulative)
117
+ @series = series!(series)
69
118
  end
70
119
 
71
120
  def model
@@ -126,11 +175,11 @@ module Janela
126
175
  single_value? && !prominence.nil?
127
176
  end
128
177
 
129
- # Only a bar has a bar to put a value on. A line, a ring, a table and a
130
- # single value ignore the flag, so a pane switched between renderers keeps
131
- # it (ADR 054).
178
+ # A bar has a bar to put a value on, and a line has an end to write its
179
+ # latest value at (ADR 054, ADR 062). A ring, a table and a single value
180
+ # ignore the flag, so a pane switched between renderers keeps it.
132
181
  def labelled?
133
- value_labels && chart? && renderer == "bar"
182
+ value_labels && chart?
134
183
  end
135
184
 
136
185
  def boxed?
@@ -211,7 +260,7 @@ module Janela
211
260
 
212
261
  def turbo_frame_id
213
262
  self.class.turbo_frame_id(model: model, measure: measure, by: dimension, as: renderer,
214
- granularity: @granularity, limit: limit, sort: sort, snapshot: snapshot)
263
+ granularity: @granularity, limit: limit, sort: sort, cumulative: cumulative, series: @series_given, snapshot: snapshot)
215
264
  end
216
265
 
217
266
  # A pane row may carry its own title, which is the first analyst authored
@@ -222,12 +271,70 @@ module Janela
222
271
  base = if single_value?
223
272
  measure.to_s.humanize
224
273
  else
225
- by = "#{measure.to_s.humanize} by #{dimension.to_s.humanize}"
274
+ by = "#{series_label} by #{dimension.to_s.humanize}"
226
275
  time? ? "#{by} per #{granularity}" : by
227
276
  end
228
277
  frozen? ? "#{base} as of #{snapshot.taken_at.strftime('%-d %b %Y')}" : base
229
278
  end
230
279
 
280
+ # What a series is called in a legend and at the head of its column: its
281
+ # title if it has one, else the measure's name, then the dimension's when
282
+ # it is not the pane's own, then "to date" when it is a running total. The
283
+ # pane's own series is named by the same rule, so the same measure to date
284
+ # and per period never read the same (ADR 062).
285
+ def series_label(each = nil)
286
+ return each.title if each&.title.present?
287
+
288
+ label = (each ? each.measure : measure).to_s.humanize
289
+ label = "#{label} by #{each.dimension.to_s.humanize}" if each && each.dimension != dimension
290
+ (each ? each.cumulative : cumulative) ? I18n.t("janela.series.to_date", label: label) : label
291
+ end
292
+
293
+ # Series are drawn by a bar, a line and a table. A ring ignores them, as it
294
+ # ignores a height, and a stored pane holds none (ADR 062, ADR 051).
295
+ def series?
296
+ series.any? && !frozen? && !ring?
297
+ end
298
+
299
+ # The series after the pane's own, once a result has been read, each with
300
+ # its values by label over the same buckets.
301
+ def drawn_series
302
+ series? ? series : []
303
+ end
304
+
305
+ # A series' numbers read the way its own measure formats them (ADR 020).
306
+ def series_format(each, value)
307
+ definition.measure!(each.measure).format(value)
308
+ end
309
+
310
+ def series_tick_format(each)
311
+ declared = definition.measure!(each.measure)
312
+ { prefix: declared.prefix.to_s, suffix: declared.suffix.to_s, ratio: declared.ratio? }
313
+ end
314
+
315
+ # What the chart controller needs for each further series: the values and
316
+ # the formatted strings in the primary's label order, and how to draw it.
317
+ def chart_series(result)
318
+ drawn_series.map do |each|
319
+ { label: series_label(each), type: each.renderer, axis: each.axis,
320
+ values: result.keys.map { |label| each.values[label]&.to_f },
321
+ tick_format: series_tick_format(each),
322
+ formatted: result.keys.map { |label| series_format(each, each.values[label]) } }
323
+ end
324
+ end
325
+
326
+ # A right axis exists when a series asks for it, and then both are titled
327
+ # with the names of the series on them: an untitled second scale is how
328
+ # two scales mislead (ADR 062). Nil without one, so a chart with every
329
+ # series on the left is drawn as it always was.
330
+ def axis_titles
331
+ right = drawn_series.select { |each| each.axis == "right" }
332
+ return if right.empty?
333
+
334
+ left = [ series_label ] + drawn_series.reject { |each| each.axis == "right" }.map { |each| series_label(each) }
335
+ { left: left.join(", "), right: right.map { |each| series_label(each) }.join(", ") }
336
+ end
337
+
231
338
  # What identifies this pane's data inside a snapshot.
232
339
  def lookup_key
233
340
  { "model" => model.model_name.route_key, "measure" => measure.to_s, "dimension" => dimension&.to_s,
@@ -235,7 +342,10 @@ module Janela
235
342
  end
236
343
 
237
344
  def result(on: nil)
238
- return snapshot.stored_result(self) if frozen?
345
+ # A snapshot keeps per-bucket numbers, and a running total of them is
346
+ # summed on read as a live one is, so what is stored does not change
347
+ # (ADR 062, ADR 009).
348
+ return accumulated(snapshot.stored_result(self), cumulative) if frozen?
239
349
 
240
350
  # Default, then fixed, then the reader's: a frame's own permanent
241
351
  # filter narrows first, a host's per-record filter narrows again, and
@@ -266,7 +376,7 @@ module Janela
266
376
  end
267
377
 
268
378
  def measure_header
269
- measure.to_s.humanize
379
+ series_label
270
380
  end
271
381
 
272
382
  # The two filters a click on this label writes, for a time pane: the start
@@ -344,10 +454,62 @@ module Janela
344
454
 
345
455
  # A time pane's series is read as buckets, not labels, and the buckets
346
456
  # are kept: the labels alone cannot say what range a click covers.
457
+ # Further series are read the same way, each by its own dimension, and
458
+ # every bucket any of them has becomes one shared x axis, zero where a
459
+ # series has no row (ADR 062).
347
460
  def time_result(on)
348
- series = definition.series(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity)
349
- @buckets = series.keys.to_h { |bucket| [ dimension_definition.label(bucket, granularity), bucket ] }
350
- series.transform_keys { |bucket| dimension_definition.label(bucket, granularity) }
461
+ read = definition.series(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity)
462
+ if series?
463
+ extra = series.map { |each| [ each, definition.series(each.measure, by: each.dimension, where: applicable_filters, on: on, granularity: granularity) ] }
464
+ buckets = spanning((read.keys + extra.flat_map { |_, result| result.keys }).map(&:in_time_zone))
465
+ read = aligned(read, buckets, measure)
466
+ extra.each { |each, result| each.values = labelled(accumulated(aligned(result, buckets, each.measure), each.cumulative)) }
467
+ end
468
+ @buckets = read.keys.to_h { |bucket| [ dimension_definition.label(bucket, granularity), bucket ] }
469
+ labelled(accumulated(read, cumulative))
470
+ end
471
+
472
+ def labelled(result)
473
+ result.transform_keys { |bucket| dimension_definition.label(bucket, granularity) }
474
+ end
475
+
476
+ # Every bucket from the first any series has to the last, one step
477
+ # apart. Groupdate fills the gaps inside each series' own range, and two
478
+ # series whose ranges do not meet would otherwise leave a hole between
479
+ # them on the shared axis.
480
+ def spanning(instants)
481
+ return [] if instants.empty?
482
+
483
+ step = Dimension::STEPS.fetch(granularity)
484
+ buckets = []
485
+ bucket = instants.min
486
+ last = instants.max
487
+ while bucket <= last
488
+ raise BadRequest, "these series span more than #{MAX_BUCKETS} #{granularity} buckets, so they cannot share one axis" if buckets.size >= MAX_BUCKETS
489
+
490
+ buckets << bucket
491
+ bucket += step
492
+ end
493
+ buckets
494
+ end
495
+
496
+ # Groupdate keys a date column's buckets as Date and a timestamp's as
497
+ # Time, so two series are joined through the instant each bucket starts.
498
+ # A bucket with no row is zero for a count or a sum and a gap for an
499
+ # average, a minimum, a maximum or a ratio, which have no value there.
500
+ def aligned(result, buckets, name)
501
+ by_instant = result.transform_keys(&:in_time_zone)
502
+ empty = definition.measure!(name).additive? ? 0 : nil
503
+ buckets.to_h { |bucket| [ bucket, by_instant.fetch(bucket, empty) ] }
504
+ end
505
+
506
+ # A running total, summed after the read so the query, the buckets and
507
+ # the click are what they always were (ADR 062).
508
+ def accumulated(result, running)
509
+ return result unless running
510
+
511
+ total = 0
512
+ result.transform_values { |value| total += (value || 0) }
351
513
  end
352
514
 
353
515
  # The buckets that lie wholly inside the range the filters name. Janela
@@ -413,12 +575,65 @@ module Janela
413
575
 
414
576
  # A yes or a no from a URL or a form, with unset the same as no. Anything
415
577
  # else is refused rather than read as one of them.
416
- def value_labels!(value)
417
- case value.to_s
418
- when "", "0", "false" then false
419
- when "1", "true" then true
420
- else raise BadRequest, "value_labels must be 1 or 0, got #{value.inspect}"
421
- end
578
+ def flag!(name, value)
579
+ self.class.flag!(name, value)
580
+ end
581
+
582
+ # A running total is across buckets, so only a time pane has one. On a
583
+ # category pane or a single value it is refused with its reason rather
584
+ # than ignored: a row whose dimension is changed to a category is told on
585
+ # the form, not left drawing something else (ADR 062).
586
+ def cumulative!(value)
587
+ running = flag!("cumulative", value)
588
+ raise BadRequest, "cumulative applies to a time dimension, and this pane is not over one" if running && !time?
589
+ raise BadRequest, unsummable(measure) if running && !definition.measure!(measure).additive?
590
+
591
+ running
592
+ end
593
+
594
+ def unsummable(name)
595
+ "cumulative applies to a measure that adds up, a count or a sum, and #{name.inspect} is " \
596
+ "#{definition.measure!(name.to_sym).aggregate == :ratio ? 'a ratio' : "an #{definition.measure!(name.to_sym).aggregate}"}"
597
+ end
598
+
599
+ def series!(value)
600
+ @series_given = value
601
+ return [] if value.blank?
602
+ raise BadRequest, "series must be a list of hashes, got #{value.class}" unless value.is_a?(Array) && value.all?(Hash)
603
+ raise BadRequest, "series apply to a time dimension, and this pane is not over one" unless time?
604
+ raise BadRequest, "a pane may carry at most #{MAX_SERIES} series, got #{value.size}" if value.size > MAX_SERIES
605
+
606
+ built = value.map { |each| series_from(each.stringify_keys) }
607
+ named = built.map { |each| [ each.measure, each.dimension, each.cumulative ] }
608
+ raise BadRequest, "a series is given twice: the same measure, dimension and cumulative" unless named == named.uniq
609
+
610
+ built
611
+ end
612
+
613
+ # Only what the model declared, by the name it declared it under, as a
614
+ # companion is (ADR 025, ADR 051). What is left unsaid is the pane's own.
615
+ def series_from(given)
616
+ unknown = given.keys - SERIES_KEYS
617
+ raise BadRequest, "a series takes #{SERIES_KEYS.join(', ')}, not #{unknown.join(', ')}" if unknown.any?
618
+
619
+ name = given["measure"].to_s
620
+ raise BadRequest, "a series needs a measure" if name.blank?
621
+ raise BadRequest, "#{name.inspect} is not a measure of #{model}. Declared: #{definition.measures.keys.join(', ')}" unless definition.measures.key?(name.to_sym)
622
+
623
+ by = given["by"].present? ? given["by"].to_s.to_sym : dimension
624
+ declared = definition.dimensions.fetch(by) { raise BadRequest, "#{by.inspect} is not a dimension of #{model}" }
625
+ raise BadRequest, "#{by.inspect} is not a time dimension, and a series shares the pane's buckets" unless declared.time?
626
+
627
+ drawn = given["as"].present? ? given["as"].to_s : (CANVAS.include?(renderer) ? renderer : "line")
628
+ raise BadRequest, "a series is drawn as #{CANVAS.join(' or ')}, not #{drawn.inspect}" unless CANVAS.include?(drawn)
629
+ axis = given["axis"].present? ? given["axis"].to_s : "left"
630
+ raise BadRequest, "a series' axis is #{AXES.join(' or ')}, not #{axis.inspect}" unless AXES.include?(axis)
631
+
632
+ running = flag!("cumulative", given["cumulative"])
633
+ raise BadRequest, unsummable(name) if running && !definition.measures.fetch(name.to_sym).additive?
634
+
635
+ Series.new(measure: name.to_sym, dimension: by, cumulative: running,
636
+ renderer: drawn, axis: axis, title: given["title"].presence&.to_s)
422
637
  end
423
638
 
424
639
  def prominence!(value)
@@ -435,10 +650,15 @@ module Janela
435
650
  step
436
651
  end
437
652
 
653
+ # The reader's filters this pane applies: every one but its own
654
+ # dimension's, which is the cross-filtering rule, and when the pane asks
655
+ # for it, only those its model can apply (ADR 063). A host's fixed and a
656
+ # frame's default filter are not read here and are refused when wrong.
438
657
  def applicable_filters
439
- return filters if single_value?
440
-
441
- filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
658
+ @applicable_filters ||= begin
659
+ given = ignore_unknown_filters ? definition.applicable(filters) : filters
660
+ single_value? ? given : given.reject { |key, _| key.to_s.start_with?(ransack_name) }
661
+ end
442
662
  end
443
663
  end
444
664
  end
@@ -5,5 +5,5 @@
5
5
  <%= link_to t("janela.actions.edit"), edit_frame_path(@frame) %>
6
6
  </p>
7
7
 
8
- <%# charts: false because this page loads no chart runtime (ADR 018). %>
9
- <%= janela_frame @frame, charts: false %>
8
+ <%# A table unless the host has said its layout draws charts, because this page cannot see that (ADR 018, ADR 059). %>
9
+ <%= janela_frame @frame, charts: Janela.charts_on_own_pages %>
@@ -63,6 +63,46 @@
63
63
  <span class="janela-hint"><%= t("janela.companions.hint") %></span>
64
64
  </div>
65
65
 
66
+ <%# A running total and further series apply to a time pane (ADR 062). The
67
+ series slots are plain selects, one blank beyond those the row holds, up
68
+ to the most a pane may carry, since these pages have no JavaScript to
69
+ add a row on demand (ADR 018). Each slot leads with its measure, which
70
+ is how the browser's params keep one slot's fields together. %>
71
+ <% if granularity_applies %>
72
+ <div class="janela-field">
73
+ <%= form.check_box :cumulative %>
74
+ <%= form.label :cumulative %>
75
+ <span class="janela-hint"><%= t("janela.series.cumulative_hint") %></span>
76
+ </div>
77
+
78
+ <%
79
+ slots = Array(pane.series).first(Janela::Query::MAX_SERIES)
80
+ slots << {} if slots.size < Janela::Query::MAX_SERIES
81
+ time_choices = definition.dimensions.values.select(&:time?).map { |dimension| [ dimension.name.to_s.humanize, dimension.name ] }
82
+ %>
83
+ <fieldset class="janela-field janela-series">
84
+ <legend><%= Janela::Pane.human_attribute_name(:series) %></legend>
85
+ <span class="janela-hint"><%= t("janela.series.hint", count: Janela::Query::MAX_SERIES) %></span>
86
+ <% slots.each_with_index do |slot, index| %>
87
+ <% slot = slot.to_h.stringify_keys %>
88
+ <div class="janela-series-slot">
89
+ <%= label_tag "pane_series_#{index}_measure", t("janela.series.measure") %>
90
+ <%= select_tag "pane[series][][measure]", options_for_select(measures, slot["measure"]), include_blank: t("janela.series.none"), id: "pane_series_#{index}_measure" %>
91
+ <%= label_tag "pane_series_#{index}_by", t("janela.series.by") %>
92
+ <%= select_tag "pane[series][][by]", options_for_select(time_choices, slot["by"]), include_blank: t("janela.series.same_as_pane"), id: "pane_series_#{index}_by" %>
93
+ <%= check_box_tag "pane[series][][cumulative]", "1", ActiveModel::Type::Boolean.new.cast(slot["cumulative"]), id: "pane_series_#{index}_cumulative" %>
94
+ <%= label_tag "pane_series_#{index}_cumulative", Janela::Pane.human_attribute_name(:cumulative) %>
95
+ <%= label_tag "pane_series_#{index}_as", t("janela.series.as") %>
96
+ <%= select_tag "pane[series][][as]", options_for_select(Janela::Query::CANVAS.map { |each| [ t("janela.renderers.#{each}"), each ] }, slot["as"]), include_blank: t("janela.series.same_as_pane"), id: "pane_series_#{index}_as" %>
97
+ <%= label_tag "pane_series_#{index}_axis", t("janela.series.axis") %>
98
+ <%= select_tag "pane[series][][axis]", options_for_select(Janela::Query::AXES.map { |each| [ t("janela.series.axes.#{each}"), each ] }, slot["axis"]), id: "pane_series_#{index}_axis" %>
99
+ <%= label_tag "pane_series_#{index}_title", Janela::Pane.human_attribute_name(:title) %>
100
+ <%= text_field_tag "pane[series][][title]", slot["title"], id: "pane_series_#{index}_title", placeholder: t("janela.panes.title_placeholder") %>
101
+ </div>
102
+ <% end %>
103
+ </fieldset>
104
+ <% end %>
105
+
66
106
  <div class="janela-field">
67
107
  <%= form.label :height %>
68
108
  <%= form.select :height, Janela::Pane::HEIGHTS.map { |step| [ t("janela.heights.#{step}"), step ] }, include_blank: t("janela.heights.automatic") %>
@@ -79,6 +119,12 @@
79
119
  <span class="janela-hint"><%= t("janela.value_labels.hint") %></span>
80
120
  </div>
81
121
 
122
+ <div class="janela-field">
123
+ <%= form.check_box :ignore_unknown_filters %>
124
+ <%= form.label :ignore_unknown_filters %>
125
+ <span class="janela-hint"><%= t("janela.ignore_unknown_filters.hint") %></span>
126
+ </div>
127
+
82
128
  <div class="janela-field">
83
129
  <%= form.label :span %>
84
130
  <%= form.select :span, Janela::Pane::SPANS.to_a %>
@@ -25,25 +25,35 @@
25
25
  "janela--chart-title-value": query.title,
26
26
  "janela--chart-selected-value": query.selected_values.to_json,
27
27
  "janela--chart-labels-value": result.keys.to_json,
28
- "janela--chart-values-value": result.values.map(&:to_f).to_json,
28
+ "janela--chart-values-value": result.values.map { |measured| measured&.to_f }.to_json,
29
29
  "janela--chart-formatted-value": result.values.map { |measured| query.format(measured) }.to_json,
30
30
  "janela--chart-tick-format-value": query.tick_format.to_json,
31
31
  "janela--chart-filters-value": query.filters_for(result.keys).to_json,
32
32
  "janela--chart-fixed-height-value": (true if query.boxed?),
33
- "janela--chart-value-labels-value": (true if query.labelled?)
33
+ "janela--chart-value-labels-value": (true if query.labelled?),
34
+ # Only with further series, so a chart without them is handed
35
+ # exactly what it always was (ADR 062).
36
+ "janela--chart-label-value": (query.series_label if query.series?),
37
+ "janela--chart-series-value": (query.chart_series(result).to_json if query.series?),
38
+ "janela--chart-axes-value": query.axis_titles&.to_json
34
39
  }) %>
35
40
  <%= query.boxed? ? tag.div(canvas, class: "janela-chart-box janela-h-#{query.height}") : canvas %>
36
41
  </figure>
37
42
  <% else %>
38
43
  <%= tag.table class: "janela-pane", aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) } do %>
39
44
  <caption><%= query.title %></caption>
40
- <%# A header row only when there are companions to name: two columns read
41
- without one, and a table with none is what it always was (ADR 051). %>
42
- <% if query.companion_columns.any? %>
45
+ <%# A header row only when there are columns to name: two columns read
46
+ without one, and a table with none is what it always was (ADR 051).
47
+ A further series is a column after the measure's and before any
48
+ companion, which is also the table a chart falls back to (ADR 062). %>
49
+ <% if query.companion_columns.any? || query.drawn_series.any? %>
43
50
  <thead>
44
51
  <tr>
45
52
  <th scope="col"><%= query.dimension_header %></th>
46
53
  <th scope="col"><%= query.measure_header %></th>
54
+ <% query.drawn_series.each do |series| %>
55
+ <th scope="col"><%= query.series_label(series) %></th>
56
+ <% end %>
47
57
  <% query.companion_columns.each do |column| %>
48
58
  <%= tag.th column.header, scope: "col", class: ("janela-fact" if column.fact) %>
49
59
  <% end %>
@@ -63,6 +73,9 @@
63
73
  <% end %>
64
74
  </td>
65
75
  <td><%= query.format(measured) %></td>
76
+ <% query.drawn_series.each do |series| %>
77
+ <td><%= query.series_format(series, series.values[label]) %></td>
78
+ <% end %>
66
79
  <% query.companion_columns.each do |column| %>
67
80
  <%= tag.td column.cells[label], class: ("janela-fact" if column.fact) %>
68
81
  <% end %>
@@ -25,6 +25,9 @@ en:
25
25
  prominence: Prominence
26
26
  value_labels: Show each bar's value
27
27
  companions: Beside the label
28
+ cumulative: Running total
29
+ series: More series
30
+ ignore_unknown_filters: Ignore filters this model cannot apply
28
31
  title: Title
29
32
  janela:
30
33
  errors:
@@ -76,8 +79,23 @@ en:
76
79
  measures: Measures
77
80
  dimensions: Dimensions
78
81
  hint: Up to three, drawn as columns in a table. A dimension shows a value only where every row of its group shares one.
82
+ ignore_unknown_filters:
83
+ hint: For a pane beside panes over another model. A click there names a dimension this model does not have, and this pane stays as it is instead of saying the request is not allowed. A filter nobody's model declares is still a mistake on every other pane.
79
84
  value_labels:
80
- hint: Draws the value at the end of each bar of a bar chart, if every one fits. Other charts ignore it.
85
+ hint: Draws the value at the end of each bar of a bar chart, if every one fits, and a line's latest value at its end. A ring ignores it.
86
+ series:
87
+ to_date: "%{label} to date"
88
+ cumulative_hint: Adds the measure up across the buckets, so the pane reads as a total to date. Applies to a time dimension.
89
+ hint: Up to %{count} more measures over the same buckets, each a line or bars on the left axis or a right one. A table draws them as columns. A ring ignores them.
90
+ measure: Measure
91
+ by: Over
92
+ as: Drawn as
93
+ axis: Axis
94
+ none: None
95
+ same_as_pane: Same as the pane
96
+ axes:
97
+ left: Left
98
+ right: Right
81
99
  sorts:
82
100
  desc: Largest first
83
101
  asc: Smallest first
@@ -0,0 +1,9 @@
1
+ class AddSeriesToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # A running total of a time pane's measure, and up to three more series
4
+ # drawn beside it (ADR 062). Null is what every existing pane is: unset,
5
+ # and drawn exactly as before.
6
+ add_column :janela_panes, :cumulative, :boolean
7
+ add_column :janela_panes, :series, :json
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ class AddIgnoreUnknownFiltersToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # Whether a pane drops a reader's filter its model cannot apply rather
4
+ # than refusing the request (ADR 063). Null is what every existing pane
5
+ # is: unset, and refused exactly as before.
6
+ add_column :janela_panes, :ignore_unknown_filters, :boolean
7
+ end
8
+ end
data/docs/agents.md CHANGED
@@ -15,6 +15,29 @@ them with whatever MCP library your application already runs. Janela
15
15
  registers nothing, detects nothing and serves nothing, because it cannot
16
16
  know who is asking and your application can.
17
17
 
18
+ ## Setting it up
19
+
20
+ ```
21
+ bin/rails generate janela:mcp
22
+ ```
23
+
24
+ This writes `bin/janela-mcp`, a stdio server built on the official Ruby SDK, and
25
+ adds a `janela` entry to `.mcp.json`, which Claude Code and other MCP clients
26
+ read to find it. Both are yours from then on. It leaves an existing server or
27
+ entry alone, and it does not touch your Gemfile: if `mcp` is not in your bundle
28
+ it says which line to add (`gem "mcp"`).
29
+
30
+ Open `bin/janela-mcp` and write the scope. It is the one thing the generator
31
+ cannot know: who the agent acts for, and what that caller may read. It cannot be
32
+ your controller's `policy_scope`, which needs a request, and called without one
33
+ it does not fail but answers a different question and reads the wrong rows
34
+ (ADR 057). Until you write it, every tool refuses to read and says so. The file
35
+ also refuses to run outside development, so an agent never reaches real data
36
+ until you decide it should.
37
+
38
+ The rest of this page explains what is in that file, for a host that would
39
+ rather write it by hand or register the tools with another library.
40
+
18
41
  ## Building the tools
19
42
 
20
43
  ```ruby
@@ -43,6 +66,7 @@ tool is called. They are cheap to construct.
43
66
  | `update_pane` | Change a pane. An invalid change leaves it as it was. | yes |
44
67
  | `remove_pane` | Remove a pane and close the gap. | yes |
45
68
  | `move_pane` | Move a pane one place up or down. | yes |
69
+ | `update_frame` | Change a frame's `columns` and `gap`. Its name, key and owner are not an agent's to change. | yes |
46
70
 
47
71
  The write tools are off unless you build the tools with `write: true`. Their
48
72
  inputs are the pane's own attributes, the ones the README
@@ -50,8 +74,8 @@ names, and nothing else: an attribute that is not a pane's is refused rather
50
74
  than dropped. A pane Janela would refuse is refused with its own reason, for
51
75
  example `Measure "nonsense" is not a measure of Order`.
52
76
 
53
- There is no tool to create a frame or take a snapshot. Your code finds or makes
54
- a frame with `Janela::Frame.for` (ADR 041), and a snapshot needs an owner and a
77
+ There is no tool to create a frame or take a snapshot, and none to change who owns
78
+ one. Your code finds or makes a frame with `Janela::Frame.for` (ADR 041), and a snapshot needs an owner and a
55
79
  scope you have named (ADR 033, ADR 034).
56
80
 
57
81
  ## Registering them
@@ -88,6 +112,13 @@ end
88
112
  `mcp` is not a dependency of Janela, and a host using another library writes the
89
113
  same few lines against it.
90
114
 
115
+ ## What an agent cannot reach
116
+
117
+ A server run with `bin/janela-mcp` reads and changes the database of the
118
+ environment it starts in, which is development. It cannot reach a dashboard on a
119
+ deployed host: that needs an authenticated path only your application can give,
120
+ because Janela serves no endpoint and does not know who is calling (ADR 053).
121
+
91
122
  ## What Janela does not do
92
123
 
93
124
  It does not serve MCP, detect a server, edit your configuration or install