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/lib/janela/definition.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
data/lib/janela/dimension.rb
CHANGED
|
@@ -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
|
|
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
|
|
data/lib/janela/measure.rb
CHANGED
|
@@ -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 /" },
|
data/lib/janela/version.rb
CHANGED
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.
|
|
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.
|
|
246
|
+
Janela 0.16.0: one migration, if you use stored frames.
|
|
235
247
|
|
|
236
|
-
A
|
|
237
|
-
|
|
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:
|
|
242
|
-
|
|
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
|