janela 0.12.0 → 0.14.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 +36 -1
- data/README.md +31 -26
- data/UPGRADING.md +60 -1
- data/app/assets/javascripts/janela/chart_controller.js +54 -3
- data/app/assets/stylesheets/vitral.css +21 -0
- data/app/controllers/janela/application_controller.rb +2 -2
- data/app/controllers/janela/panes_controller.rb +1 -1
- data/app/controllers/janela/queries_controller.rb +1 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +1 -0
- data/app/helpers/janela/frames_helper.rb +23 -5
- data/app/models/janela/pane.rb +1 -1
- data/app/models/janela/query.rb +30 -2
- data/app/views/janela/frames/_pane.html.erb +3 -2
- data/app/views/janela/panes/_form.html.erb +6 -0
- data/app/views/janela/queries/_query.html.erb +17 -15
- data/config/locales/en.yml +6 -0
- data/db/migrate/20261006000001_add_value_labels_to_janela_panes.rb +7 -0
- data/docs/agents.md +97 -0
- data/docs/composing.md +14 -14
- data/docs/decisions/020-formatting-belongs-to-the-measure.md +4 -1
- data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +187 -0
- data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +221 -0
- data/docs/decisions/INDEX.md +11 -9
- data/docs/multi-tenancy.md +22 -4
- data/docs/roadmap.md +37 -13
- data/docs/theming.md +6 -4
- data/lib/janela/doctor.rb +66 -8
- data/lib/janela/tools.rb +221 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +16 -0
- metadata +16 -8
data/lib/janela/tools.rb
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
module Janela
|
|
2
|
+
# What an agent can do to a dashboard, as plain Ruby (ADR 053). A host builds
|
|
3
|
+
# one where it knows who is asking, hands it the answer to "what may this
|
|
4
|
+
# caller read", and registers #all with whatever MCP library it runs. Janela
|
|
5
|
+
# registers nothing and serves nothing, and depends on no MCP library.
|
|
6
|
+
#
|
|
7
|
+
# tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) })
|
|
8
|
+
# tools.all # name, description, input_schema, read_only
|
|
9
|
+
# tools.call("read_pane", pane_id: 4) # a Hash, or a Janela::Error
|
|
10
|
+
#
|
|
11
|
+
# Every read passes the host's relation as on:, because Query#result with no
|
|
12
|
+
# on: reads the model's default scope, and the refusal in ADR 032 lives in the
|
|
13
|
+
# controller. Frames and panes are found through the same scope, so what a
|
|
14
|
+
# caller may change is what it may see.
|
|
15
|
+
class Tools
|
|
16
|
+
Tool = Data.define(:name, :description, :input_schema, :read_only)
|
|
17
|
+
|
|
18
|
+
# The attributes of a pane a caller may set: the ones the README names. The
|
|
19
|
+
# frame, the position and the timestamps are not an agent's to write.
|
|
20
|
+
PANE_ATTRIBUTES = %w[kind model measure dimension renderer granularity limit span title height prominence
|
|
21
|
+
value_labels companions heading body link partial].freeze
|
|
22
|
+
DIRECTIONS = %w[up down].freeze
|
|
23
|
+
READS = %w[describe_vocabulary list_frames get_frame read_pane].freeze
|
|
24
|
+
WRITES = %w[add_pane update_pane remove_pane move_pane].freeze
|
|
25
|
+
|
|
26
|
+
def initialize(scope: nil, write: false)
|
|
27
|
+
unless scope.respond_to?(:call)
|
|
28
|
+
raise Unscoped, "Janela::Tools was built without a scope. Pass the answer to what this caller may read: " \
|
|
29
|
+
"Janela::Tools.new(scope: ->(model) { policy_scope(model) }). A tool with no scope would " \
|
|
30
|
+
"read every row of every model, which is what ADR 032 refuses."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@scope = scope
|
|
34
|
+
@write = write
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# What there is to register. Needs no scope, because a definition says
|
|
38
|
+
# nothing about whose data it will read; the scope matters when a tool is
|
|
39
|
+
# called, and a host usually learns who is calling only then (ADR 053).
|
|
40
|
+
def self.all(write: false)
|
|
41
|
+
names = write ? READS + WRITES : READS
|
|
42
|
+
names.map { |name| definitions.fetch(name) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def all
|
|
46
|
+
self.class.all(write: @write)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Arguments may arrive with string keys, as they do from a protocol.
|
|
50
|
+
# Anything a tool refuses is a Janela::Error, which an adapter reports to
|
|
51
|
+
# the agent as the tool's own error rather than a crash.
|
|
52
|
+
def call(name, arguments = {})
|
|
53
|
+
raise NotFound, "no tool named #{name}" unless all.any? { |tool| tool.name == name.to_s }
|
|
54
|
+
|
|
55
|
+
__send__(name, **arguments.to_h.symbolize_keys)
|
|
56
|
+
rescue ArgumentError => error
|
|
57
|
+
raise error unless error.message.match?(/unknown keyword|missing keyword/)
|
|
58
|
+
|
|
59
|
+
raise BadRequest, "#{name}: #{error.message}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
def describe_vocabulary
|
|
64
|
+
{ models: Janela.definitions.map { |definition| model_hash(definition) },
|
|
65
|
+
renderers: Janela.renderers.map(&:to_s),
|
|
66
|
+
granularities: Janela.granularities.map(&:to_s),
|
|
67
|
+
limits: Pane::OFFERED_LIMITS }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def list_frames
|
|
71
|
+
{ frames: scoped(Frame).includes(:panes).order(:id).map { |frame| frame_hash(frame).merge(pane_count: frame.panes.size) } }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def get_frame(frame_id:)
|
|
75
|
+
frame = frame_in_scope(frame_id)
|
|
76
|
+
frame_hash(frame).merge(panes: frame.panes.map { |pane| pane_hash(pane) })
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def read_pane(pane_id:, filters: {})
|
|
80
|
+
pane = pane_in_scope(pane_id)
|
|
81
|
+
raise BadRequest, "pane #{pane.id} holds #{pane.kind}, not a query, so it has no values" unless pane.query?
|
|
82
|
+
|
|
83
|
+
query = pane.query(filters: filters.to_h.stringify_keys)
|
|
84
|
+
result = query.result(on: scoped(pane.definition.model))
|
|
85
|
+
reading = { pane_id: pane.id, measure: pane.measure, dimension: pane.dimension }
|
|
86
|
+
return reading.merge(value: result&.to_f, formatted: query.format(result)) if query.single_value?
|
|
87
|
+
|
|
88
|
+
reading.merge(values: result.transform_values(&:to_f), formatted: result.transform_values { |value| query.format(value) })
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def add_pane(frame_id:, **attributes)
|
|
92
|
+
frame = frame_in_scope(frame_id)
|
|
93
|
+
pane = frame.panes.build(pane_attributes(attributes))
|
|
94
|
+
pane.save || refuse(pane)
|
|
95
|
+
pane_hash(pane)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def update_pane(pane_id:, **attributes)
|
|
99
|
+
pane = pane_in_scope(pane_id)
|
|
100
|
+
pane.update(pane_attributes(attributes)) || refuse(pane)
|
|
101
|
+
pane_hash(pane)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def remove_pane(pane_id:)
|
|
105
|
+
pane = pane_in_scope(pane_id)
|
|
106
|
+
pane.destroy
|
|
107
|
+
pane.frame.resequence_panes!
|
|
108
|
+
{ removed: pane.id }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def move_pane(pane_id:, direction:)
|
|
112
|
+
raise BadRequest, "direction is #{DIRECTIONS.join(' or ')}, not #{direction.inspect}" unless DIRECTIONS.include?(direction.to_s)
|
|
113
|
+
|
|
114
|
+
pane = pane_in_scope(pane_id)
|
|
115
|
+
direction.to_s == "up" ? pane.move_up : pane.move_down
|
|
116
|
+
pane_hash(pane.reload)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The host's relation for a model, and a refusal if it is not one. A scope
|
|
120
|
+
# that answers with an Array or nil has not scoped anything.
|
|
121
|
+
def scoped(model)
|
|
122
|
+
relation = @scope.call(model)
|
|
123
|
+
return relation if relation.respond_to?(:where)
|
|
124
|
+
|
|
125
|
+
raise Unscoped, "the scope answered #{relation.class} for #{model.name}, not a relation. " \
|
|
126
|
+
"It should return what policy_scope(#{model.name}) returns."
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def frame_in_scope(id)
|
|
130
|
+
scoped(Frame).find_by(id: id) || raise(NotFound, "no frame #{id}")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# One query, with the scope as a subquery: a pane is reachable only
|
|
134
|
+
# through a frame the caller may see.
|
|
135
|
+
def pane_in_scope(id)
|
|
136
|
+
Pane.where(frame_id: scoped(Frame).select(:id)).find_by(id: id) || raise(NotFound, "no pane #{id}")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def pane_attributes(attributes)
|
|
140
|
+
unknown = attributes.keys.map(&:to_s) - PANE_ATTRIBUTES
|
|
141
|
+
raise BadRequest, "not a pane attribute: #{unknown.join(', ')}. A pane takes #{PANE_ATTRIBUTES.join(', ')}." if unknown.any?
|
|
142
|
+
|
|
143
|
+
attributes.transform_keys(&:to_s)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def refuse(record)
|
|
147
|
+
raise BadRequest, record.errors.full_messages.to_sentence
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def model_hash(definition)
|
|
151
|
+
{ name: definition.model.model_name.route_key,
|
|
152
|
+
measures: definition.measures.keys.map(&:to_s),
|
|
153
|
+
dimensions: definition.dimensions.values.map { |dimension| { name: dimension.name.to_s, time: dimension.time? } } }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def frame_hash(frame)
|
|
157
|
+
frame.attributes.slice("id", "name", "columns", "gap", "key", "owner_type", "owner_id", "default_model", "default_where").compact.symbolize_keys
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def pane_hash(pane)
|
|
161
|
+
pane.attributes.slice("id", "position", "span", *(PANE_ATTRIBUTES - %w[span])).compact.symbolize_keys
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
class << self
|
|
165
|
+
private
|
|
166
|
+
def definitions
|
|
167
|
+
{
|
|
168
|
+
"describe_vocabulary" => tool("describe_vocabulary", read: true,
|
|
169
|
+
description: "The models, measures, dimensions, renderers and granularities that can be asked for. Offer nothing else.",
|
|
170
|
+
properties: {}),
|
|
171
|
+
"list_frames" => tool("list_frames", read: true,
|
|
172
|
+
description: "The frames the caller may see: id, name, owner and how many panes each holds.",
|
|
173
|
+
properties: {}),
|
|
174
|
+
"get_frame" => tool("get_frame", read: true, required: %w[frame_id],
|
|
175
|
+
description: "One frame with its panes in position order.",
|
|
176
|
+
properties: { frame_id: { type: "integer" } }),
|
|
177
|
+
"read_pane" => tool("read_pane", read: true, required: %w[pane_id],
|
|
178
|
+
description: "The values a query pane shows, read through the caller's scope. Filters are Ransack predicates on declared dimensions.",
|
|
179
|
+
properties: { pane_id: { type: "integer" }, filters: { type: "object" } }),
|
|
180
|
+
"add_pane" => tool("add_pane", required: %w[frame_id],
|
|
181
|
+
description: "Add a pane to the end of a frame. A pane Janela would refuse is refused with its own reason.",
|
|
182
|
+
properties: { frame_id: { type: "integer" } }.merge(pane_properties)),
|
|
183
|
+
"update_pane" => tool("update_pane", required: %w[pane_id],
|
|
184
|
+
description: "Change a pane's attributes. An invalid change leaves the pane as it was.",
|
|
185
|
+
properties: { pane_id: { type: "integer" } }.merge(pane_properties)),
|
|
186
|
+
"remove_pane" => tool("remove_pane", required: %w[pane_id],
|
|
187
|
+
description: "Remove a pane and close the gap it leaves.",
|
|
188
|
+
properties: { pane_id: { type: "integer" } }),
|
|
189
|
+
"move_pane" => tool("move_pane", required: %w[pane_id direction],
|
|
190
|
+
description: "Move a pane one place up or down its frame.",
|
|
191
|
+
properties: { pane_id: { type: "integer" }, direction: { type: "string", enum: DIRECTIONS } })
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def tool(name, description:, properties:, read: false, required: [])
|
|
196
|
+
Tool.new(name: name, description: description, read_only: read,
|
|
197
|
+
input_schema: { type: "object", properties: properties, required: required, additionalProperties: false })
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def pane_properties
|
|
201
|
+
{ kind: { type: "string", enum: Pane::KINDS },
|
|
202
|
+
model: { type: "string", description: "A model's route key, as describe_vocabulary lists it" },
|
|
203
|
+
measure: { type: "string" },
|
|
204
|
+
dimension: { type: "string" },
|
|
205
|
+
renderer: { type: "string", enum: Janela.renderers.map(&:to_s) },
|
|
206
|
+
granularity: { type: "string", enum: Janela.granularities.map(&:to_s) },
|
|
207
|
+
limit: { type: "integer", minimum: Pane::LIMITS.min, maximum: Pane::LIMITS.max },
|
|
208
|
+
span: { type: "integer", minimum: Pane::SPANS.min, maximum: Pane::SPANS.max },
|
|
209
|
+
title: { type: "string" },
|
|
210
|
+
height: { type: "integer", enum: Pane::HEIGHTS.to_a },
|
|
211
|
+
prominence: { type: "integer", enum: Pane::PROMINENCES.to_a },
|
|
212
|
+
value_labels: { type: "boolean", description: "Draw each bar's value on a bar chart; other renderers ignore it" },
|
|
213
|
+
companions: { type: "array", items: { type: "string" } },
|
|
214
|
+
heading: { type: "string" },
|
|
215
|
+
body: { type: "string" },
|
|
216
|
+
link: { type: "string", description: "A path on this site, starting with /" },
|
|
217
|
+
partial: { type: "string" } }
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
data/lib/janela/version.rb
CHANGED
data/lib/janela.rb
CHANGED
|
@@ -11,6 +11,7 @@ require "janela/measure"
|
|
|
11
11
|
require "janela/dimension"
|
|
12
12
|
require "janela/doctor"
|
|
13
13
|
require "janela/host_routes"
|
|
14
|
+
require "janela/tools"
|
|
14
15
|
|
|
15
16
|
module Janela
|
|
16
17
|
class Error < StandardError; end
|
|
@@ -165,4 +166,19 @@ module Janela
|
|
|
165
166
|
def self.offered_limits
|
|
166
167
|
Pane::OFFERED_LIMITS
|
|
167
168
|
end
|
|
169
|
+
|
|
170
|
+
# The steps a pane's height and prominence take, and how many companion
|
|
171
|
+
# columns a table may carry, for a page that offers them as controls
|
|
172
|
+
# (ADR 027, ADR 047, ADR 050, ADR 051).
|
|
173
|
+
def self.heights
|
|
174
|
+
Pane::HEIGHTS.to_a
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.prominences
|
|
178
|
+
Pane::PROMINENCES.to_a
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def self.max_companions
|
|
182
|
+
Query::MAX_COMPANIONS
|
|
183
|
+
end
|
|
168
184
|
end
|
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.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jay Killeen
|
|
@@ -146,6 +146,8 @@ files:
|
|
|
146
146
|
- db/migrate/20260930000001_add_height_to_janela_panes.rb
|
|
147
147
|
- db/migrate/20260930000002_add_prominence_to_janela_panes.rb
|
|
148
148
|
- db/migrate/20260930000003_add_companions_to_janela_panes.rb
|
|
149
|
+
- db/migrate/20261006000001_add_value_labels_to_janela_panes.rb
|
|
150
|
+
- docs/agents.md
|
|
149
151
|
- docs/composing.md
|
|
150
152
|
- docs/decisions/001-built-to-be-forked.md
|
|
151
153
|
- docs/decisions/002-measures-and-dimensions-over-ransack.md
|
|
@@ -199,6 +201,8 @@ files:
|
|
|
199
201
|
- docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md
|
|
200
202
|
- docs/decisions/051-a-table-can-carry-companion-columns.md
|
|
201
203
|
- docs/decisions/052-how-the-project-is-run.md
|
|
204
|
+
- docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md
|
|
205
|
+
- docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md
|
|
202
206
|
- docs/decisions/INDEX.md
|
|
203
207
|
- docs/multi-tenancy.md
|
|
204
208
|
- docs/naming.md
|
|
@@ -212,6 +216,7 @@ files:
|
|
|
212
216
|
- lib/janela/host_routes.rb
|
|
213
217
|
- lib/janela/measure.rb
|
|
214
218
|
- lib/janela/model.rb
|
|
219
|
+
- lib/janela/tools.rb
|
|
215
220
|
- lib/janela/version.rb
|
|
216
221
|
- lib/tasks/janela.rake
|
|
217
222
|
homepage: https://github.com/retail-tasker/janela
|
|
@@ -224,14 +229,17 @@ metadata:
|
|
|
224
229
|
bug_tracker_uri: https://github.com/retail-tasker/janela/issues
|
|
225
230
|
rubygems_mfa_required: 'true'
|
|
226
231
|
post_install_message: |
|
|
227
|
-
Janela 0.
|
|
228
|
-
has anything to do.
|
|
232
|
+
Janela 0.14.0: one migration, if you use stored frames.
|
|
229
233
|
|
|
230
|
-
A pane can now
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
234
|
+
A pane can now draw each bar's value on a bar chart (ADR 054), which
|
|
235
|
+
adds a nullable value_labels column to janela_panes. Run
|
|
236
|
+
bin/rails janela:install:migrations and then bin/rails db:migrate. If
|
|
237
|
+
you never use stored frames there is nothing to do.
|
|
238
|
+
|
|
239
|
+
Also: a chart's axis now reads in the measure's own format, so a
|
|
240
|
+
currency chart gains its prefix and a ratio reads as percentages. A
|
|
241
|
+
pane Janela will not draw is now that pane's own sentence and no
|
|
242
|
+
longer an error for the whole host page.
|
|
235
243
|
|
|
236
244
|
Steps: UPGRADING.md in this gem, or
|
|
237
245
|
https://github.com/retail-tasker/janela/blob/main/UPGRADING.md
|