janela 0.2.0 → 0.3.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 +54 -1
- data/README.md +203 -21
- data/UPGRADING.md +136 -0
- data/app/assets/javascripts/janela/chart_controller.js +16 -8
- data/app/assets/javascripts/janela/{dashboard_controller.js → frame_controller.js} +11 -4
- data/app/assets/stylesheets/janela.css +163 -0
- data/app/controllers/janela/application_controller.rb +45 -0
- data/app/controllers/janela/frames_controller.rb +57 -0
- data/app/controllers/janela/panes_controller.rb +66 -13
- data/app/controllers/janela/queries_controller.rb +17 -0
- data/app/controllers/janela/{snapshot_panes_controller.rb → snapshot_queries_controller.rb} +7 -5
- data/app/helpers/janela/{dashboard_helper.rb → frames_helper.rb} +25 -9
- data/app/models/janela/frame.rb +28 -0
- data/app/models/janela/pane.rb +107 -85
- data/app/models/janela/query.rb +149 -0
- data/app/models/janela/snapshot.rb +6 -6
- data/app/views/janela/frames/_card.html.erb +7 -0
- data/app/views/janela/frames/_form.html.erb +23 -0
- data/app/views/janela/frames/_frame.html.erb +4 -0
- data/app/views/janela/frames/_pane.html.erb +12 -0
- data/app/views/janela/frames/edit.html.erb +25 -0
- data/app/views/janela/frames/index.html.erb +15 -0
- data/app/views/janela/frames/new.html.erb +5 -0
- data/app/views/janela/frames/show.html.erb +9 -0
- data/app/views/janela/panes/_form.html.erb +58 -0
- data/app/views/janela/panes/_row.html.erb +12 -0
- data/app/views/janela/panes/edit.html.erb +5 -0
- data/app/views/janela/panes/new.html.erb +22 -0
- data/app/views/janela/panes/show.html.erb +3 -44
- data/app/views/janela/queries/_query.html.erb +47 -0
- data/app/views/janela/queries/show.html.erb +4 -0
- data/app/views/janela/shared/_errors.html.erb +7 -0
- data/app/views/layouts/janela/application.html.erb +26 -0
- data/config/importmap.rb +1 -1
- data/config/locales/en.yml +64 -0
- data/config/routes.rb +15 -2
- data/db/migrate/20260916000001_create_janela_frames.rb +13 -0
- data/db/migrate/20260916000002_create_janela_panes.rb +22 -0
- data/docs/decisions/001-built-to-be-forked.md +4 -0
- data/docs/decisions/005-pane-urls-and-mount-path.md +4 -1
- data/docs/decisions/009-snapshots.md +5 -2
- data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +134 -0
- data/docs/decisions/011-panes-do-not-render-in-the-host-layout.md +81 -0
- data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
- data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
- data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
- data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
- data/docs/decisions/016-the-styling-vocabulary.md +100 -0
- data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
- data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
- data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
- data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
- data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
- data/docs/decisions/INDEX.md +24 -5
- data/docs/multi-tenancy.md +175 -0
- data/lib/janela/definition.rb +23 -9
- data/lib/janela/dimension.rb +5 -1
- data/lib/janela/doctor.rb +219 -0
- data/lib/janela/engine.rb +15 -2
- data/lib/janela/measure.rb +68 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +24 -1
- data/lib/tasks/janela.rake +6 -0
- metadata +57 -4
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Fitting Janela into a multi tenant application
|
|
2
|
+
|
|
3
|
+
Janela holds no tenancy of its own. There is no tenant setting, no
|
|
4
|
+
default scope of ours on your models, and nothing to configure. The
|
|
5
|
+
engine asks your application two questions and does what it is told.
|
|
6
|
+
|
|
7
|
+
| Question | How your application answers | If it says nothing |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| What may this request read? | `policy_scope(model)` on the controller Janela inherits | Everything: `model.all` |
|
|
10
|
+
| What owns a frame being created? | `janela_frame_owner` on the same controller | Nothing: a nil owner |
|
|
11
|
+
|
|
12
|
+
Both are ordinary methods on your `ApplicationController`, found by
|
|
13
|
+
duck typing. Pundit defines the first for you. Anything else, you
|
|
14
|
+
define in about five lines. ADR 019 has the reasoning for asking
|
|
15
|
+
rather than being configured: ownership is per-request state, and a
|
|
16
|
+
setting cannot hold it.
|
|
17
|
+
|
|
18
|
+
## What goes through your scope
|
|
19
|
+
|
|
20
|
+
Everything. There is no path through the engine that reads a record
|
|
21
|
+
without asking first:
|
|
22
|
+
|
|
23
|
+
- The index of frames, and each frame page.
|
|
24
|
+
- Every editing action on a frame or a pane, so another tenant's frame
|
|
25
|
+
is a 404 to rename or delete as much as to read.
|
|
26
|
+
- Every pane's own query, on Janela's pages and inline in yours. The
|
|
27
|
+
measure is calculated over `policy_scope(Order)`, not over `Order`.
|
|
28
|
+
- An ad hoc pane URL, which is the same query by another route.
|
|
29
|
+
- A snapshot, read through `policy_scope(Janela::Snapshot)`.
|
|
30
|
+
|
|
31
|
+
A pane rendered inline in your own page runs in your request, so the
|
|
32
|
+
same scope applies there as in the engine's controllers. Nothing is
|
|
33
|
+
calculated in a background context where the current tenant would have
|
|
34
|
+
gone missing.
|
|
35
|
+
|
|
36
|
+
## With Pundit
|
|
37
|
+
|
|
38
|
+
Write a policy for Janela's own record. The class name is namespaced,
|
|
39
|
+
because the model is:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# app/policies/janela/frame_policy.rb
|
|
43
|
+
module Janela
|
|
44
|
+
class FramePolicy < ApplicationPolicy
|
|
45
|
+
class Scope < ApplicationPolicy::Scope
|
|
46
|
+
def resolve
|
|
47
|
+
scope.where(owner: Current.account)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
class ApplicationController < ActionController::Base
|
|
56
|
+
include Pundit::Authorization
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
def janela_frame_owner
|
|
60
|
+
Current.account
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Your own models keep the policies they already have. Janela calls
|
|
66
|
+
`policy_scope(Order)` and gets whatever `OrderPolicy::Scope` returns.
|
|
67
|
+
|
|
68
|
+
## With acts_as_tenant
|
|
69
|
+
|
|
70
|
+
Your own models are already scoped, so the only thing Janela needs
|
|
71
|
+
told about is its own tables. Define `policy_scope` as the adapter:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
class ApplicationController < ActionController::Base
|
|
75
|
+
set_current_tenant_through_filter
|
|
76
|
+
before_action :set_tenant
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
def policy_scope(model)
|
|
80
|
+
case model.name
|
|
81
|
+
when "Janela::Frame" then model.where(owner: ActsAsTenant.current_tenant)
|
|
82
|
+
else model.all # acts_as_tenant has already scoped your own models
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def janela_frame_owner
|
|
87
|
+
ActsAsTenant.current_tenant
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## With CanCanCan, or with your own
|
|
93
|
+
|
|
94
|
+
The same adapter, pointed at whatever you use:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
def policy_scope(model)
|
|
98
|
+
model.accessible_by(current_ability)
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Janela never asks how the answer was arrived at. A method that takes a
|
|
103
|
+
class and returns a relation is the whole contract.
|
|
104
|
+
|
|
105
|
+
## With one tenant
|
|
106
|
+
|
|
107
|
+
Define nothing. `policy_scope` is absent, every query runs over
|
|
108
|
+
`model.all`, and a frame is created with a nil owner because nothing
|
|
109
|
+
is filtering on one.
|
|
110
|
+
|
|
111
|
+
## What owns a frame
|
|
112
|
+
|
|
113
|
+
`Janela::Frame belongs_to :owner, polymorphic: true, optional: true`.
|
|
114
|
+
It can point at an account, a team, a user or anything else you scope
|
|
115
|
+
by, and Janela reads nothing from it. It exists so your scope has a
|
|
116
|
+
column.
|
|
117
|
+
|
|
118
|
+
Return the tenant from `janela_frame_owner` and the engine assigns it
|
|
119
|
+
to a frame it creates. Skip that method while your scope filters by
|
|
120
|
+
owner and the analyst's new dashboard is saved with no owner, then
|
|
121
|
+
hidden by your own policy the instant it is saved. It looks like the
|
|
122
|
+
save failed silently. `bin/rails janela:doctor` reports this for you:
|
|
123
|
+
it asks your policy for a scope over frames and looks for an owner in
|
|
124
|
+
what comes back.
|
|
125
|
+
|
|
126
|
+
## Snapshots are the rough edge
|
|
127
|
+
|
|
128
|
+
`janela_snapshots` has no owner column. A snapshot is a name, an
|
|
129
|
+
instant, the filters it was taken under and the results. So a multi
|
|
130
|
+
tenant application has to scope it by something else:
|
|
131
|
+
|
|
132
|
+
- Keep snapshots to one tenant, or to whoever publishes.
|
|
133
|
+
- Add your own column with a migration on `janela_snapshots` and scope
|
|
134
|
+
on that.
|
|
135
|
+
- Encode the tenant in the filters a snapshot is taken under, and
|
|
136
|
+
scope on the stored name.
|
|
137
|
+
|
|
138
|
+
None of those is as clean as a frame's owner. Giving a snapshot the
|
|
139
|
+
same polymorphic owner is [issue #32](https://github.com/retail-tasker/janela/issues/32).
|
|
140
|
+
|
|
141
|
+
## Proving your wiring
|
|
142
|
+
|
|
143
|
+
Run the doctor first. It reads your application and reports the traps
|
|
144
|
+
that are visible from outside a request:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
bin/rails janela:doctor
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Then write the test that matters, which is the one that fails if a
|
|
151
|
+
scope is ever loosened. Create a frame as one tenant and ask for it as
|
|
152
|
+
another:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
test "another tenant's frame is a 404" do
|
|
156
|
+
frame = Janela::Frame.create!(name: "Theirs", owner: accounts(:acme))
|
|
157
|
+
|
|
158
|
+
sign_in users(:globex_analyst)
|
|
159
|
+
get janela.frame_path(frame)
|
|
160
|
+
|
|
161
|
+
assert_response :not_found
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
This repository's dummy application is a worked example of all of the
|
|
166
|
+
above, in
|
|
167
|
+
[test/dummy/app/controllers/application_controller.rb](https://github.com/retail-tasker/janela/blob/main/test/dummy/app/controllers/application_controller.rb),
|
|
168
|
+
scoped by a `?tenant=` parameter standing in for a session.
|
|
169
|
+
|
|
170
|
+
## What Janela will never do
|
|
171
|
+
|
|
172
|
+
Add a tenant filter of its own. Your scope is the only one, so there
|
|
173
|
+
is nothing to double filter and nothing that looks enforced while
|
|
174
|
+
being unenforceable. Janela can be handed any relation through `on:`,
|
|
175
|
+
so a guarantee made here would be a guarantee it cannot keep.
|
data/lib/janela/definition.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Janela
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def measure(name, **aggregate)
|
|
12
|
-
measures[name] = Measure.build(name, **aggregate)
|
|
12
|
+
measures[name] = Measure.build(name, model: model, **aggregate).tap { |measure| reject_boolean_column!(measure) }
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def dimension(name, through: nil, column: nil, granularity: nil)
|
|
@@ -36,17 +36,35 @@ module Janela
|
|
|
36
36
|
else
|
|
37
37
|
grouped = relation.group(dimension.attribute).order(Arel.sql("#{measure.sql_alias} DESC"))
|
|
38
38
|
grouped = grouped.limit(limit!(limit)) if limit
|
|
39
|
-
measure.apply(grouped)
|
|
39
|
+
measure.apply(grouped).transform_keys { |value| value.nil? ? Dimension::NONE : value }
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def dimension!(name)
|
|
44
|
-
dimensions.fetch(name) { raise
|
|
44
|
+
dimensions.fetch(name) { raise NotFound, "#{model} has no janela dimension #{name.inspect}" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def measure!(name)
|
|
48
|
+
measures.fetch(name) { raise NotFound, "#{model} has no janela measure #{name.inspect}" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ActiveRecord casts an aggregate back through the column's own type, so
|
|
52
|
+
# AVG over a boolean returns true rather than a ratio. Say so at
|
|
53
|
+
# declaration rather than rendering a meaningless pane.
|
|
54
|
+
def reject_boolean_column!(measure)
|
|
55
|
+
return unless measure.column && Measure::NUMERIC.include?(measure.aggregate)
|
|
56
|
+
return unless model.type_for_attribute(measure.column).type == :boolean
|
|
57
|
+
|
|
58
|
+
raise Error, "measure #{measure.name.inspect} takes #{measure.aggregate} of the boolean " \
|
|
59
|
+
"#{model}##{measure.column}, which ActiveRecord casts back to true or false. " \
|
|
60
|
+
"Declare dimension #{measure.column.inspect} instead and read the split."
|
|
61
|
+
rescue ActiveRecord::ActiveRecordError
|
|
62
|
+
nil # no database to ask yet; a query will raise on its own if it cannot run
|
|
45
63
|
end
|
|
46
64
|
|
|
47
65
|
def limit!(value)
|
|
48
66
|
limit = Integer(value, exception: false)
|
|
49
|
-
raise
|
|
67
|
+
raise BadRequest, "limit must be a whole number from 1 to 1000, got #{value.inspect}" unless limit&.between?(1, 1000)
|
|
50
68
|
limit
|
|
51
69
|
end
|
|
52
70
|
|
|
@@ -74,12 +92,8 @@ module Janela
|
|
|
74
92
|
dropped = params.keys.reject { |key| applied.any? { |name| key.to_s.start_with?(name) } }
|
|
75
93
|
return if dropped.empty?
|
|
76
94
|
|
|
77
|
-
raise
|
|
95
|
+
raise BadRequest, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
|
|
78
96
|
"Declare a janela dimension, or add it to ransackable_attributes."
|
|
79
97
|
end
|
|
80
|
-
|
|
81
|
-
def measure!(name)
|
|
82
|
-
measures.fetch(name) { raise Error, "#{model} has no janela measure #{name.inspect}" }
|
|
83
|
-
end
|
|
84
98
|
end
|
|
85
99
|
end
|
data/lib/janela/dimension.rb
CHANGED
|
@@ -2,6 +2,10 @@ module Janela
|
|
|
2
2
|
class Dimension
|
|
3
3
|
GRANULARITIES = %w[hour day week month quarter year].freeze
|
|
4
4
|
|
|
5
|
+
# A group of rows whose dimension is null. Labelled rather than blank, and
|
|
6
|
+
# filtered with Ransack's null predicate rather than an empty string.
|
|
7
|
+
NONE = "(none)".freeze
|
|
8
|
+
|
|
5
9
|
LABELS = {
|
|
6
10
|
"hour" => ->(t) { t.strftime("%Y-%m-%d %H:00") },
|
|
7
11
|
"day" => ->(t) { t.strftime("%Y-%m-%d") },
|
|
@@ -29,7 +33,7 @@ module Janela
|
|
|
29
33
|
|
|
30
34
|
def self.granularity!(value)
|
|
31
35
|
value = value.to_s
|
|
32
|
-
raise
|
|
36
|
+
raise BadRequest, "unknown granularity #{value.inspect}, use one of #{GRANULARITIES.join(', ')}" unless GRANULARITIES.include?(value)
|
|
33
37
|
value
|
|
34
38
|
end
|
|
35
39
|
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
# Reads a host application and reports what it still has to do. Only ever
|
|
3
|
+
# reads and reports: the checks are the install and upgrade traps that
|
|
4
|
+
# experience says actually break a host (ADR 015).
|
|
5
|
+
class Doctor
|
|
6
|
+
# code is the check that produced the finding, set by the runner rather
|
|
7
|
+
# than by each check, so the two can never drift apart.
|
|
8
|
+
Finding = Struct.new(:severity, :summary, :detail, :code, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# Run in this order, and each one names the finding it produces: a host
|
|
11
|
+
# silences a check by that name (ADR 021).
|
|
12
|
+
CHECKS = %i[stale_identifiers unmounted_engine unmigrated_tables unregistered_controllers
|
|
13
|
+
through_dimensions_without_an_allowlist frames_nobody_will_own
|
|
14
|
+
unauthenticated_endpoints].freeze
|
|
15
|
+
|
|
16
|
+
# Identifiers a previous version of Janela used, and what replaced them.
|
|
17
|
+
RENAMED = {
|
|
18
|
+
"janela--dashboard" => "janela--frame",
|
|
19
|
+
"janela_dashboard" => "janela_frame",
|
|
20
|
+
"janela/dashboard_controller" => "janela/frame_controller",
|
|
21
|
+
"janela/dashboard_controller.js" => "janela/frame_controller.js",
|
|
22
|
+
"Janela::DashboardHelper" => "Janela::FramesHelper",
|
|
23
|
+
"Janela::PanesController" => "Janela::QueriesController",
|
|
24
|
+
"Janela::SnapshotPanesController" => "Janela::SnapshotQueriesController"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
SEARCHED = %w[app config lib].freeze
|
|
28
|
+
READABLE = %w[.rb .erb .js .erb.html .html.erb .haml .slim .yml].freeze
|
|
29
|
+
|
|
30
|
+
def initialize(root)
|
|
31
|
+
@root = Pathname.new(root)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def report(out)
|
|
35
|
+
findings, silenced = all_findings.partition { |finding| !silenced?(finding) }
|
|
36
|
+
|
|
37
|
+
if findings.empty?
|
|
38
|
+
out.puts "Janela: nothing to fix."
|
|
39
|
+
else
|
|
40
|
+
out.puts "Janela found #{findings.size} #{'thing'.pluralize(findings.size)} to look at."
|
|
41
|
+
findings.each do |finding|
|
|
42
|
+
out.puts
|
|
43
|
+
out.puts "#{finding.severity.to_s.upcase} (#{finding.code}): #{finding.summary}"
|
|
44
|
+
out.puts finding.detail
|
|
45
|
+
end
|
|
46
|
+
out.puts
|
|
47
|
+
out.puts "Silence one you have judged a false alarm, in config/initializers/janela.rb:"
|
|
48
|
+
out.puts " Janela.silenced_checks = %w[#{findings.first.code}]"
|
|
49
|
+
out.puts
|
|
50
|
+
out.puts "Steps for a version upgrade are in UPGRADING.md."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Said out loud every run, because a silence nobody remembers is how a
|
|
54
|
+
# real finding goes unread.
|
|
55
|
+
out.puts "Silenced: #{silenced.map(&:code).uniq.join(', ')}." if silenced.any?
|
|
56
|
+
findings.none? { |finding| finding.severity == :error }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check
|
|
60
|
+
all_findings.reject { |finding| silenced?(finding) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
def all_findings
|
|
65
|
+
@all_findings ||= CHECKS.flat_map { |name| findings_from(name) }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Struct responds to to_a, so a single finding is wrapped by hand rather
|
|
69
|
+
# than with Array(), which would take it apart into its members.
|
|
70
|
+
def findings_from(name)
|
|
71
|
+
found = send(name)
|
|
72
|
+
found = [ found ] unless found.is_a?(Array)
|
|
73
|
+
found.compact.each { |finding| finding.code = name.to_s.dasherize }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def silenced?(finding)
|
|
77
|
+
Janela.silenced_checks.map(&:to_s).include?(finding.code)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stale_identifiers
|
|
81
|
+
RENAMED.filter_map do |old, new|
|
|
82
|
+
files = source_files.select { |file| file.read.include?(old) }
|
|
83
|
+
next if files.empty?
|
|
84
|
+
|
|
85
|
+
Finding.new(severity: :error,
|
|
86
|
+
summary: "#{old} is now #{new}",
|
|
87
|
+
detail: files.map { |file| " #{file.relative_path_from(@root)}" }.join("\n"))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def unmounted_engine
|
|
92
|
+
return if engine_mounted?
|
|
93
|
+
|
|
94
|
+
Finding.new(severity: :error,
|
|
95
|
+
summary: "Janela::Engine is not mounted",
|
|
96
|
+
detail: " Add to config/routes.rb, at whatever path suits you:\n" \
|
|
97
|
+
" mount Janela::Engine => \"/insights\"")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The mount root serves an index of frames, so a host that upgrades
|
|
101
|
+
# without running the migrations finds an exception where its dashboards
|
|
102
|
+
# were. Only a host that mounts the engine needs the tables at all.
|
|
103
|
+
def unmigrated_tables
|
|
104
|
+
return unless engine_mounted?
|
|
105
|
+
|
|
106
|
+
missing = [ Janela::Frame, Janela::Pane, Janela::Snapshot ].reject(&:table_exists?).map(&:table_name)
|
|
107
|
+
return if missing.empty?
|
|
108
|
+
|
|
109
|
+
Finding.new(severity: :error,
|
|
110
|
+
summary: "#{missing.to_sentence} #{missing.one? ? 'is' : 'are'} missing",
|
|
111
|
+
detail: " Janela's own pages read these the moment the engine is mounted. Run:\n" \
|
|
112
|
+
" bin/rails janela:install:migrations\n" \
|
|
113
|
+
" bin/rails db:migrate")
|
|
114
|
+
rescue StandardError
|
|
115
|
+
nil # no database to ask yet
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def engine_mounted?
|
|
119
|
+
Rails.application.routes.routes.any? { |route| route.app.respond_to?(:app) && route.app.app == Janela::Engine }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def unregistered_controllers
|
|
123
|
+
registered = source_files.any? { |file| file.read.include?("janela--frame") }
|
|
124
|
+
return if registered
|
|
125
|
+
|
|
126
|
+
Finding.new(severity: :error,
|
|
127
|
+
summary: "Janela's Stimulus controllers are not registered anywhere",
|
|
128
|
+
detail: " Without them a dashboard renders but nothing cross-filters, which\n" \
|
|
129
|
+
" looks like nothing happening at all. Register both:\n" \
|
|
130
|
+
" application.register(\"janela--frame\", JanelaFrameController)\n" \
|
|
131
|
+
" application.register(\"janela--chart\", JanelaChartController)")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Ransack's allowlist is per class, so a dimension read through an
|
|
135
|
+
# association needs the associated model to allow the attribute too.
|
|
136
|
+
def through_dimensions_without_an_allowlist
|
|
137
|
+
janela_models.flat_map do |model|
|
|
138
|
+
model.janela.dimensions.values.select(&:through).filter_map do |dimension|
|
|
139
|
+
association = model.reflect_on_association(dimension.through)
|
|
140
|
+
next unless association
|
|
141
|
+
|
|
142
|
+
allowed = association.klass.ransackable_attributes.map(&:to_s)
|
|
143
|
+
next if allowed.include?(dimension.column.to_s)
|
|
144
|
+
|
|
145
|
+
Finding.new(severity: :error,
|
|
146
|
+
summary: "#{association.klass} does not allow filtering on #{dimension.column}",
|
|
147
|
+
detail: " #{model}'s #{dimension.name.inspect} dimension reads it through " \
|
|
148
|
+
"#{dimension.through.inspect}, and Ransack's allowlist is per class. Add to " \
|
|
149
|
+
"#{association.klass}:\n" \
|
|
150
|
+
" def self.ransackable_attributes(_auth_object = nil) = " \
|
|
151
|
+
"%w[#{(allowed + [ dimension.column.to_s ]).uniq.join(' ')}]")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A host whose policy filters frames by owner, but which never tells
|
|
157
|
+
# Janela what owns a new one, creates frames its own scope then hides.
|
|
158
|
+
# The failure is silent, and a typo in the method name looks the same as
|
|
159
|
+
# not defining it, which is the cost of asking by duck typing (ADR 019).
|
|
160
|
+
def frames_nobody_will_own
|
|
161
|
+
parent = Janela.parent_controller.safe_constantize
|
|
162
|
+
return unless parent&.private_method_defined?(:policy_scope) || parent&.method_defined?(:policy_scope)
|
|
163
|
+
return if parent.private_method_defined?(:janela_frame_owner) || parent.method_defined?(:janela_frame_owner)
|
|
164
|
+
return unless Janela::Frame.table_exists? && scope_filters_frames_by_owner?(parent)
|
|
165
|
+
|
|
166
|
+
Finding.new(severity: :error,
|
|
167
|
+
summary: "#{parent} scopes frames by owner but defines no janela_frame_owner",
|
|
168
|
+
detail: " A frame created through Janela's own form would have no owner, and your\n" \
|
|
169
|
+
" own scope would then hide it. Define this on #{parent}:\n" \
|
|
170
|
+
" def janela_frame_owner\n" \
|
|
171
|
+
" Current.account # whatever your policy scope filters frames by\n" \
|
|
172
|
+
" end")
|
|
173
|
+
rescue StandardError
|
|
174
|
+
nil # no database or no policy to ask; nothing can be concluded
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Asking the policy rather than reading its source: a scope that narrows
|
|
178
|
+
# frames is one that will hide an unowned one.
|
|
179
|
+
def scope_filters_frames_by_owner?(parent)
|
|
180
|
+
scope = parent.allocate.send(:policy_scope, Janela::Frame)
|
|
181
|
+
scope.to_sql.include?("owner")
|
|
182
|
+
rescue StandardError
|
|
183
|
+
false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Whether an endpoint is public depends on what the host's controller
|
|
187
|
+
# does, which cannot be determined by reading, so this observes and says
|
|
188
|
+
# so rather than declaring anything safe.
|
|
189
|
+
def unauthenticated_endpoints
|
|
190
|
+
parent = Janela.parent_controller.safe_constantize
|
|
191
|
+
return unless parent
|
|
192
|
+
|
|
193
|
+
filters = parent._process_action_callbacks.map(&:filter).map(&:to_s)
|
|
194
|
+
return if filters.any? { |filter| filter.match?(/authenticat|require_user|require_login|login_required/) }
|
|
195
|
+
|
|
196
|
+
Finding.new(severity: :warning,
|
|
197
|
+
summary: "no authentication filter found on #{parent}",
|
|
198
|
+
detail: " Janela's controllers inherit #{parent}, so they are as public as it is,\n" \
|
|
199
|
+
" and this check only reads its filters: if you authenticate another way\n" \
|
|
200
|
+
" this is a false alarm. Otherwise see Securing dashboards in the README.")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def janela_models
|
|
204
|
+
Rails.application.eager_load!
|
|
205
|
+
ActiveRecord::Base.descendants.select { |model| model.respond_to?(:janela) && model.janela }
|
|
206
|
+
rescue StandardError
|
|
207
|
+
[]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def source_files
|
|
211
|
+
@source_files ||= SEARCHED.flat_map do |directory|
|
|
212
|
+
path = @root.join(directory)
|
|
213
|
+
next [] unless path.directory?
|
|
214
|
+
|
|
215
|
+
path.glob("**/*").select { |file| file.file? && READABLE.any? { |extension| file.to_s.end_with?(extension) } }
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
data/lib/janela/engine.rb
CHANGED
|
@@ -8,10 +8,23 @@ module Janela
|
|
|
8
8
|
ActiveSupport.on_load(:active_record) { extend Janela::Model }
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
# isolate_namespace keeps engine helpers out of the host, but the
|
|
11
|
+
# isolate_namespace keeps engine helpers out of the host, but the frame
|
|
12
12
|
# helpers are the engine's public API and belong in the host's views.
|
|
13
13
|
initializer "janela.helpers" do
|
|
14
|
-
ActiveSupport.on_load(:action_view) { include Janela::
|
|
14
|
+
ActiveSupport.on_load(:action_view) { include Janela::FramesHelper }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# janela_frame renders the engine's own partials from a host's page, and
|
|
18
|
+
# an engine's views are otherwise only on the lookup path of its own
|
|
19
|
+
# controllers.
|
|
20
|
+
initializer "janela.views" do
|
|
21
|
+
ActiveSupport.on_load(:action_controller) { append_view_path Janela::Engine.root.join("app/views") }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Janela's own layout links janela.css, and a host on Sprockets serves it
|
|
25
|
+
# in production only if something declared it.
|
|
26
|
+
initializer "janela.assets" do |app|
|
|
27
|
+
app.config.assets.precompile << "janela.css" if app.config.respond_to?(:assets) && app.config.assets.respond_to?(:precompile)
|
|
15
28
|
end
|
|
16
29
|
|
|
17
30
|
initializer "janela.importmap", before: "importmap" do |app|
|
data/lib/janela/measure.rb
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
module Janela
|
|
2
2
|
class Measure
|
|
3
3
|
AGGREGATES = %i[sum count average minimum maximum].freeze
|
|
4
|
+
# Aggregates whose answer is a number, so a boolean column would have its
|
|
5
|
+
# result cast back to true or false by ActiveRecord.
|
|
6
|
+
NUMERIC = %i[sum average].freeze
|
|
7
|
+
# Declared beside the aggregate, so they are taken out before the one
|
|
8
|
+
# remaining option is read as the aggregate.
|
|
9
|
+
FORMATS = %i[precision prefix suffix].freeze
|
|
10
|
+
# Where neither the aggregate nor the column says how many decimal places
|
|
11
|
+
# the measure means, two: enough for a rate or a currency, and short of
|
|
12
|
+
# the noise a float carries (issue #25).
|
|
13
|
+
FALLBACK_PRECISION = 2
|
|
4
14
|
|
|
5
|
-
attr_reader :name, :aggregate, :column
|
|
15
|
+
attr_reader :name, :aggregate, :column, :prefix, :suffix
|
|
6
16
|
|
|
7
|
-
def self.build(name, **options)
|
|
17
|
+
def self.build(name, model: nil, **options)
|
|
18
|
+
format = options.extract!(*FORMATS)
|
|
8
19
|
aggregate, column = options.first
|
|
9
20
|
unless options.size == 1 && AGGREGATES.include?(aggregate)
|
|
10
21
|
raise Error, "measure #{name.inspect} needs exactly one of #{AGGREGATES.join(', ')}"
|
|
11
22
|
end
|
|
12
23
|
|
|
13
|
-
new(name, aggregate, column == true ? nil : column)
|
|
24
|
+
new(name, aggregate, column == true ? nil : column, model: model, **format)
|
|
14
25
|
end
|
|
15
26
|
|
|
16
|
-
def initialize(name, aggregate, column)
|
|
27
|
+
def initialize(name, aggregate, column, model: nil, precision: nil, prefix: nil, suffix: nil)
|
|
17
28
|
@name = name
|
|
18
29
|
@aggregate = aggregate
|
|
19
30
|
@column = column
|
|
31
|
+
@model = model
|
|
32
|
+
@precision = precision!(precision)
|
|
33
|
+
@prefix = prefix
|
|
34
|
+
@suffix = suffix
|
|
20
35
|
end
|
|
21
36
|
|
|
22
37
|
def apply(relation)
|
|
@@ -28,5 +43,54 @@ module Janela
|
|
|
28
43
|
def sql_alias
|
|
29
44
|
"#{aggregate}_#{column || 'all'}"
|
|
30
45
|
end
|
|
46
|
+
|
|
47
|
+
# How many decimal places this measure means. Counting rows has none, and
|
|
48
|
+
# a decimal column already declares its own scale, so money and counts
|
|
49
|
+
# read correctly with nothing declared at all (ADR 020).
|
|
50
|
+
def precision
|
|
51
|
+
@precision || column_scale || FALLBACK_PRECISION
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Rendering, never rounding: the number itself reaches a snapshot and a
|
|
55
|
+
# comparison at full precision, so a stored pane reads back under whatever
|
|
56
|
+
# format is declared later (ADR 009). Anything that is not a number is
|
|
57
|
+
# left alone, since minimum of a string is still that string.
|
|
58
|
+
def format(value)
|
|
59
|
+
return "" if value.nil?
|
|
60
|
+
return value.to_s unless value.is_a?(Numeric)
|
|
61
|
+
|
|
62
|
+
"#{prefix}#{rounded(value)}#{suffix}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
def rounded(value)
|
|
67
|
+
ActiveSupport::NumberHelper.number_to_rounded(value, precision: precision,
|
|
68
|
+
delimiter: I18n.t("number.format.delimiter", default: ","))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Asking the schema rather than the value: one bucket of a measure can
|
|
72
|
+
# land on a whole number without the measure being a whole number.
|
|
73
|
+
def column_scale
|
|
74
|
+
return 0 if aggregate == :count || column.nil?
|
|
75
|
+
|
|
76
|
+
type = @model&.type_for_attribute(column)
|
|
77
|
+
return if type.nil?
|
|
78
|
+
return 0 if type.type == :integer && aggregate != :average
|
|
79
|
+
|
|
80
|
+
type.scale if type.respond_to?(:scale)
|
|
81
|
+
rescue ActiveRecord::ActiveRecordError
|
|
82
|
+
nil # no database to ask yet
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def precision!(value)
|
|
86
|
+
return if value.nil?
|
|
87
|
+
|
|
88
|
+
places = Integer(value, exception: false)
|
|
89
|
+
unless places&.between?(0, 10)
|
|
90
|
+
raise Error, "measure #{name.inspect} takes a precision of 0 to 10 decimal places, not #{value.inspect}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
places
|
|
94
|
+
end
|
|
31
95
|
end
|
|
32
96
|
end
|
data/lib/janela/version.rb
CHANGED
data/lib/janela.rb
CHANGED
|
@@ -9,14 +9,27 @@ require "janela/model"
|
|
|
9
9
|
require "janela/definition"
|
|
10
10
|
require "janela/measure"
|
|
11
11
|
require "janela/dimension"
|
|
12
|
+
require "janela/doctor"
|
|
12
13
|
|
|
13
14
|
module Janela
|
|
14
15
|
class Error < StandardError; end
|
|
16
|
+
# Something the request named does not exist: a model, measure, dimension
|
|
17
|
+
# or a pane a snapshot did not freeze. Rendered as 404.
|
|
18
|
+
class NotFound < Error; end
|
|
19
|
+
# Something the request asked for is not allowed here: a renderer, a
|
|
20
|
+
# granularity, a limit or a filter. Rendered as 400.
|
|
21
|
+
class BadRequest < Error; end
|
|
15
22
|
|
|
16
23
|
# Janela's controllers inherit from the host's, so the host's authentication
|
|
17
24
|
# and authorisation apply to dashboards with no configuration.
|
|
18
25
|
mattr_accessor :parent_controller, default: "ApplicationController"
|
|
19
26
|
|
|
27
|
+
# Checks janela:doctor should not report, by the name it prints beside each
|
|
28
|
+
# finding. A check that is a false alarm for one application stays a false
|
|
29
|
+
# alarm, and that is a judgement made once at boot, which is what a setting
|
|
30
|
+
# is for (ADR 021).
|
|
31
|
+
mattr_accessor :silenced_checks, default: []
|
|
32
|
+
|
|
20
33
|
# Only models that declare a janela block are addressable over HTTP, keyed by
|
|
21
34
|
# the route key that appears in pane URLs (orders, sales_orders). Names are
|
|
22
35
|
# stored rather than classes so a reloaded model leaves nothing stale behind.
|
|
@@ -28,11 +41,21 @@ module Janela
|
|
|
28
41
|
registry[model.model_name.route_key] = model.name
|
|
29
42
|
end
|
|
30
43
|
|
|
44
|
+
# Every model that declares a janela block, for a form that offers a choice
|
|
45
|
+
# of them. Eager loading first, because a model nobody has referenced yet has
|
|
46
|
+
# not registered. A name that no longer resolves is left out rather than
|
|
47
|
+
# raised on: a model renamed or deleted in development leaves its old key
|
|
48
|
+
# here until a restart, and a form offering it would fail to draw at all.
|
|
49
|
+
def self.definitions
|
|
50
|
+
Rails.application.eager_load!
|
|
51
|
+
registry.sort.filter_map { |_route_key, class_name| class_name.safe_constantize&.janela }
|
|
52
|
+
end
|
|
53
|
+
|
|
31
54
|
def self.definition!(route_key)
|
|
32
55
|
# In development a model is only registered once autoloaded, so a cold
|
|
33
56
|
# lookup loads the app rather than constantizing an unvetted parameter.
|
|
34
57
|
Rails.application.eager_load! unless registry.key?(route_key)
|
|
35
|
-
class_name = registry.fetch(route_key) { raise
|
|
58
|
+
class_name = registry.fetch(route_key) { raise NotFound, "#{route_key.inspect} is not a janela model" }
|
|
36
59
|
|
|
37
60
|
class_name.constantize.janela
|
|
38
61
|
end
|