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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +23 -7
- data/UPGRADING.md +34 -0
- data/app/assets/javascripts/janela/chart_controller.js +148 -42
- data/app/assets/stylesheets/janela.css +7 -0
- data/app/controllers/janela/panes_controller.rb +2 -2
- data/app/controllers/janela/queries_controller.rb +11 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +1 -0
- data/app/helpers/janela/frames_helper.rb +35 -7
- data/app/models/janela/pane.rb +26 -2
- data/app/models/janela/query.rb +245 -25
- data/app/views/janela/frames/show.html.erb +2 -2
- data/app/views/janela/panes/_form.html.erb +46 -0
- data/app/views/janela/queries/_query.html.erb +18 -5
- data/config/locales/en.yml +19 -1
- data/db/migrate/20261029000001_add_series_to_janela_panes.rb +9 -0
- data/db/migrate/20261029000003_add_ignore_unknown_filters_to_janela_panes.rb +8 -0
- data/docs/agents.md +33 -2
- data/docs/decisions/018-a-table-is-the-universal-renderer.md +1 -0
- data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +1 -0
- data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +1 -1
- data/docs/decisions/056-the-demo-counts-its-own-visits-and-an-agent-arranges-the-dashboard.md +51 -0
- data/docs/decisions/057-a-host-is-told-about-the-agent-tools-and-a-generator-sets-them-up.md +197 -0
- data/docs/decisions/058-the-deployed-demo-is-reached-over-ssh-and-its-visits-frame-survives-a-reset.md +94 -0
- data/docs/decisions/059-a-host-can-tell-janelas-own-pages-that-its-layout-draws-charts.md +91 -0
- data/docs/decisions/060-the-demo-tells-search-engines-what-it-is-and-shows-its-own-adoption.md +79 -0
- data/docs/decisions/061-the-demo-speaks-brazilian-portuguese-under-a-prefix-and-the-gem-stays-english.md +118 -0
- data/docs/decisions/062-a-time-pane-can-accumulate-and-carry-more-series-on-two-axes.md +282 -0
- data/docs/decisions/063-a-pane-can-ignore-a-filter-its-model-cannot-apply.md +147 -0
- data/docs/decisions/INDEX.md +23 -15
- data/docs/roadmap.md +6 -4
- data/lib/generators/janela/mcp/mcp_generator.rb +51 -0
- data/lib/generators/janela/mcp/templates/janela-mcp.tt +50 -0
- data/lib/janela/definition.rb +38 -2
- data/lib/janela/dimension.rb +9 -1
- data/lib/janela/doctor.rb +10 -1
- data/lib/janela/measure.rb +8 -0
- data/lib/janela/tools.rb +33 -2
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +8 -0
- metadata +20 -6
data/app/models/janela/pane.rb
CHANGED
|
@@ -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,
|
|
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
|
data/app/models/janela/query.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
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?
|
|
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 = "#{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
|
9
|
-
<%= janela_frame @frame, charts:
|
|
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
|
|
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
|
|
41
|
-
without one, and a table with none is what it always was (ADR 051).
|
|
42
|
-
|
|
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 %>
|
data/config/locales/en.yml
CHANGED
|
@@ -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.
|
|
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
|
|
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
|