janela 0.11.0 → 0.13.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 +42 -1
- data/README.md +50 -27
- data/UPGRADING.md +57 -1
- data/app/assets/javascripts/janela/chart_controller.js +6 -1
- data/app/assets/javascripts/janela/frame_controller.js +19 -9
- data/app/assets/stylesheets/janela.css +23 -1
- data/app/assets/stylesheets/vitral.css +21 -0
- data/app/controllers/janela/panes_controller.rb +2 -2
- data/app/controllers/janela/queries_controller.rb +3 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +3 -0
- data/app/helpers/janela/frames_helper.rb +12 -4
- data/app/models/janela/pane.rb +32 -1
- data/app/models/janela/query.rb +116 -4
- data/app/views/janela/panes/_form.html.erb +25 -0
- data/app/views/janela/queries/_query.html.erb +36 -14
- data/config/locales/en.yml +19 -0
- data/db/migrate/20260930000001_add_height_to_janela_panes.rb +7 -0
- data/db/migrate/20260930000002_add_prominence_to_janela_panes.rb +7 -0
- data/db/migrate/20260930000003_add_companions_to_janela_panes.rb +8 -0
- data/docs/agents.md +97 -0
- data/docs/composing.md +14 -14
- data/docs/decisions/024-selecting-more-than-one-value.md +1 -1
- data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +1 -1
- data/docs/decisions/047-a-charts-height-is-one-of-five-steps.md +169 -0
- data/docs/decisions/048-a-frame-can-be-told-to-refresh-and-janela-never-decides-when.md +154 -0
- data/docs/decisions/049-the-null-group-is-one-more-value-in-a-selection.md +143 -0
- data/docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md +124 -0
- data/docs/decisions/051-a-table-can-carry-companion-columns.md +160 -0
- data/docs/decisions/052-how-the-project-is-run.md +93 -0
- data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +187 -0
- data/docs/decisions/INDEX.md +27 -20
- data/docs/multi-tenancy.md +22 -4
- data/docs/roadmap.md +34 -35
- data/docs/theming.md +10 -4
- data/lib/janela/definition.rb +71 -4
- data/lib/janela/dimension.rb +7 -0
- data/lib/janela/doctor.rb +66 -8
- data/lib/janela/engine.rb +13 -1
- data/lib/janela/measure.rb +56 -7
- data/lib/janela/tools.rb +220 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +1 -0
- metadata +20 -9
data/app/models/janela/query.rb
CHANGED
|
@@ -18,7 +18,22 @@ module Janela
|
|
|
18
18
|
# categories the same beside a legend that says they differ (ADR 046).
|
|
19
19
|
SERIES = 8
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# The steps a chart's height may take (ADR 047). Pane::HEIGHTS is the same
|
|
22
|
+
# range for a stored row, and a test holds the two together.
|
|
23
|
+
HEIGHTS = (1..5).freeze
|
|
24
|
+
|
|
25
|
+
# The steps a single value's prominence may take (ADR 050).
|
|
26
|
+
PROMINENCES = (1..3).freeze
|
|
27
|
+
|
|
28
|
+
# Each companion column is a query of its own, so how many a pane may run
|
|
29
|
+
# is bounded, as what one query may ask for is (ADR 051, ADR 025).
|
|
30
|
+
MAX_COMPANIONS = 3
|
|
31
|
+
|
|
32
|
+
# A column beside a table's label: a measure's formatted numbers, or a
|
|
33
|
+
# dimension's shared fact, by label.
|
|
34
|
+
Companion = Struct.new(:name, :header, :fact, :cells, keyword_init: true)
|
|
35
|
+
|
|
36
|
+
attr_reader :definition, :measure, :dimension, :renderer, :limit, :height, :prominence, :companions, :filters, :fixed, :default, :snapshot
|
|
22
37
|
|
|
23
38
|
# The helper renders the turbo frame and the controller renders its
|
|
24
39
|
# replacement, so both derive the id the same way from the same parameters.
|
|
@@ -28,7 +43,7 @@ module Janela
|
|
|
28
43
|
parts.compact.join("_")
|
|
29
44
|
end
|
|
30
45
|
|
|
31
|
-
def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
|
|
46
|
+
def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, height: nil, prominence: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
|
|
32
47
|
@definition = definition
|
|
33
48
|
@title = title
|
|
34
49
|
@measure = measure
|
|
@@ -42,6 +57,9 @@ module Janela
|
|
|
42
57
|
raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
|
|
43
58
|
@granularity = Dimension.granularity!(granularity) if granularity.present?
|
|
44
59
|
@limit = definition.limit!(limit) if limit.present?
|
|
60
|
+
@height = height!(height) if height.present?
|
|
61
|
+
@prominence = prominence!(prominence) if prominence.present?
|
|
62
|
+
@companions = companions!(companions)
|
|
45
63
|
end
|
|
46
64
|
|
|
47
65
|
def model
|
|
@@ -82,6 +100,20 @@ module Janela
|
|
|
82
100
|
!single_value? && CANVAS.include?(renderer)
|
|
83
101
|
end
|
|
84
102
|
|
|
103
|
+
# A height means a box only where there is a canvas to fill it. A ring, a
|
|
104
|
+
# table and a single value ignore one, so switching a pane between
|
|
105
|
+
# renderers never invalidates it (ADR 047).
|
|
106
|
+
# Only a single value has a headline number to make more or less of. A
|
|
107
|
+
# table, a chart and a ring ignore one, so a pane switched between renderers
|
|
108
|
+
# keeps what it had (ADR 050).
|
|
109
|
+
def prominent?
|
|
110
|
+
single_value? && !prominence.nil?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def boxed?
|
|
114
|
+
chart? && !height.nil?
|
|
115
|
+
end
|
|
116
|
+
|
|
85
117
|
def ring?
|
|
86
118
|
!single_value? && RINGS.include?(renderer)
|
|
87
119
|
end
|
|
@@ -188,9 +220,30 @@ module Janela
|
|
|
188
220
|
# selection, which this pane shows the alternatives to (ADR 040, 043).
|
|
189
221
|
on = definition.narrow(on || model.all, default) if default.present?
|
|
190
222
|
on = definition.narrow(on || model.all, fixed) if fixed.present?
|
|
191
|
-
|
|
223
|
+
primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
|
|
224
|
+
@companion_columns = companions? ? fetch_companions(primary, on) : []
|
|
225
|
+
primary
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Only a table draws companion columns, and a stored pane is the record of
|
|
229
|
+
# a moment that holds none (ADR 051).
|
|
230
|
+
def companions?
|
|
231
|
+
renderer == "table" && !single_value? && !frozen? && companions.any?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The columns beside the label, once a result has been read. Kept from the
|
|
235
|
+
# same read as the result, with the same scope and filters, the way a time
|
|
236
|
+
# pane keeps its buckets.
|
|
237
|
+
def companion_columns
|
|
238
|
+
@companion_columns || []
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def dimension_header
|
|
242
|
+
dimension.to_s.humanize
|
|
243
|
+
end
|
|
192
244
|
|
|
193
|
-
|
|
245
|
+
def measure_header
|
|
246
|
+
measure.to_s.humanize
|
|
194
247
|
end
|
|
195
248
|
|
|
196
249
|
# The two filters a click on this label writes, for a time pane: the start
|
|
@@ -290,6 +343,65 @@ module Janela
|
|
|
290
343
|
}.keys
|
|
291
344
|
end
|
|
292
345
|
|
|
346
|
+
def companions!(value)
|
|
347
|
+
return [] if value.blank?
|
|
348
|
+
raise BadRequest, "companions must be a list of measure and dimension names, got #{value.class}" unless value.is_a?(Array) && value.all? { |each| each.is_a?(String) || each.is_a?(Symbol) }
|
|
349
|
+
|
|
350
|
+
names = value.map(&:to_sym)
|
|
351
|
+
raise BadRequest, "a pane may carry at most #{MAX_COMPANIONS} companions, got #{names.size}" if names.size > MAX_COMPANIONS
|
|
352
|
+
raise BadRequest, "companions must each be named once, got #{names.map(&:inspect).join(', ')}" unless names == names.uniq
|
|
353
|
+
|
|
354
|
+
names.each { |name| companion!(name) }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Only what the model declared, by the name it declared it under: no
|
|
358
|
+
# column, no expression, nothing that becomes SQL (ADR 025, ADR 051).
|
|
359
|
+
def companion!(name)
|
|
360
|
+
declared = definition.measures.keys + definition.dimensions.keys
|
|
361
|
+
raise BadRequest, "#{name.inspect} is not a declared measure or dimension of #{model}. Declared: #{declared.join(', ')}" unless declared.include?(name)
|
|
362
|
+
raise BadRequest, "#{name.inspect} is this pane's own #{name == measure ? 'measure' : 'dimension'}" if name == measure || name == dimension
|
|
363
|
+
return if definition.measures.key?(name)
|
|
364
|
+
|
|
365
|
+
fact = definition.dimensions.fetch(name)
|
|
366
|
+
raise BadRequest, "#{name.inspect} is a time dimension, and a bucket is not a fact about a label" if fact.time?
|
|
367
|
+
raise BadRequest, "#{name.inspect} is a dimension, which a time pane has no shared fact to show" if dimension && definition.dimension!(dimension).time?
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Ordering and the limit belong to the primary measure. The companions are
|
|
371
|
+
# fetched for the labels it chose, one grouped query for each measure and
|
|
372
|
+
# one for all the dimensions (ADR 051).
|
|
373
|
+
def fetch_companions(primary, on)
|
|
374
|
+
keys = time? ? nil : primary.keys
|
|
375
|
+
facts = companions.reject { |name| definition.measures.key?(name) }
|
|
376
|
+
shared = facts.any? ? definition.facts(facts, by: dimension, where: applicable_filters, on: on, keys: keys) : {}
|
|
377
|
+
|
|
378
|
+
companions.map do |name|
|
|
379
|
+
if definition.measures.key?(name)
|
|
380
|
+
values = definition.query(name, by: dimension, where: applicable_filters, on: on, granularity: granularity, keys: keys)
|
|
381
|
+
formatted = definition.measure!(name)
|
|
382
|
+
Companion.new(name: name, header: name.to_s.humanize, fact: false,
|
|
383
|
+
cells: primary.keys.to_h { |label| [ label, formatted.format(values[label]) ] })
|
|
384
|
+
else
|
|
385
|
+
Companion.new(name: name, header: name.to_s.humanize, fact: true,
|
|
386
|
+
cells: primary.keys.to_h { |label| [ label, shared.dig(label, name).to_s ] })
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def prominence!(value)
|
|
392
|
+
step = Integer(value.to_s, exception: false)
|
|
393
|
+
raise BadRequest, "prominence must be a whole number from #{PROMINENCES.first} to #{PROMINENCES.last}, got #{value.inspect}" unless PROMINENCES.cover?(step)
|
|
394
|
+
|
|
395
|
+
step
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def height!(value)
|
|
399
|
+
step = Integer(value.to_s, exception: false)
|
|
400
|
+
raise BadRequest, "height must be a whole number from #{HEIGHTS.first} to #{HEIGHTS.last}, got #{value.inspect}" unless HEIGHTS.cover?(step)
|
|
401
|
+
|
|
402
|
+
step
|
|
403
|
+
end
|
|
404
|
+
|
|
293
405
|
def applicable_filters
|
|
294
406
|
return filters if single_value?
|
|
295
407
|
|
|
@@ -41,6 +41,31 @@
|
|
|
41
41
|
<%= form.select :limit, Janela::Pane::OFFERED_LIMITS, include_blank: t("janela.panes.no_limit") %>
|
|
42
42
|
</div>
|
|
43
43
|
|
|
44
|
+
<%# What may sit beside the label: this model's other measures and its
|
|
45
|
+
categorical dimensions. The pane's own choices are left out when they are
|
|
46
|
+
already made (ADR 051). %>
|
|
47
|
+
<%
|
|
48
|
+
companion_choices = {
|
|
49
|
+
t("janela.companions.measures") => definition.measures.keys.reject { |name| name.to_s == pane.measure }.map { |name| [ name.to_s.humanize, name ] },
|
|
50
|
+
t("janela.companions.dimensions") => definition.dimensions.values.reject { |each| each.time? || each.name.to_s == pane.dimension }.map { |each| [ each.name.to_s.humanize, each.name ] }
|
|
51
|
+
}
|
|
52
|
+
%>
|
|
53
|
+
<div class="janela-field">
|
|
54
|
+
<%= form.label :companions %>
|
|
55
|
+
<%= form.select :companions, companion_choices, {}, multiple: true %>
|
|
56
|
+
<span class="janela-hint"><%= t("janela.companions.hint") %></span>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="janela-field">
|
|
60
|
+
<%= form.label :height %>
|
|
61
|
+
<%= form.select :height, Janela::Pane::HEIGHTS.map { |step| [ t("janela.heights.#{step}"), step ] }, include_blank: t("janela.heights.automatic") %>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="janela-field">
|
|
65
|
+
<%= form.label :prominence %>
|
|
66
|
+
<%= form.select :prominence, Janela::Pane::PROMINENCES.map { |step| [ t("janela.prominences.#{step}"), step ] }, include_blank: t("janela.prominences.automatic") %>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
44
69
|
<div class="janela-field">
|
|
45
70
|
<%= form.label :span %>
|
|
46
71
|
<%= form.select :span, Janela::Pane::SPANS.to_a %>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
<%# One pane's content, without its turbo frame: the same markup whether the
|
|
2
2
|
pane came from a URL, a row, or a frame rendered inline. %>
|
|
3
3
|
<% if query.single_value? %>
|
|
4
|
-
|
|
4
|
+
<%= tag.p class: [ "janela-pane", "janela-value", ("janela-prominence-#{query.prominence}" if query.prominent?) ] do %>
|
|
5
5
|
<span class="janela-value-label"><%= query.title %></span>
|
|
6
6
|
<strong class="janela-value-number"><%= query.format(result || 0) %></strong>
|
|
7
|
-
|
|
7
|
+
<% end %>
|
|
8
8
|
<% elsif result.empty? %>
|
|
9
9
|
<p class="janela-pane janela-empty"><%= query.title %>: no data</p>
|
|
10
10
|
<% elsif query.ring? && query.ringable?(result) %>
|
|
@@ -13,22 +13,41 @@
|
|
|
13
13
|
<% title_id = "#{query.turbo_frame_id}-title" %>
|
|
14
14
|
<figure class="janela-pane">
|
|
15
15
|
<figcaption class="janela-chart-title" id="<%= title_id %>"><%= query.title %></figcaption>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
16
|
+
<%# A height puts the canvas in a box of fixed size for the chart to fill;
|
|
17
|
+
with none there is no box and the chart is what it always was (ADR 047). %>
|
|
18
|
+
<% canvas = tag.canvas(
|
|
19
|
+
class: "janela-chart", role: "img",
|
|
20
|
+
aria: { labelledby: title_id, description: (t("janela.time.click_hint") if query.time? && query.clickable?) },
|
|
21
|
+
data: {
|
|
22
|
+
controller: "janela--chart",
|
|
23
|
+
action: "janela--chart:toggle->janela--frame#toggle",
|
|
24
|
+
"janela--chart-type-value": query.renderer,
|
|
25
|
+
"janela--chart-title-value": query.title,
|
|
26
|
+
"janela--chart-selected-value": query.selected_values.to_json,
|
|
27
|
+
"janela--chart-labels-value": result.keys.to_json,
|
|
28
|
+
"janela--chart-values-value": result.values.map(&:to_f).to_json,
|
|
29
|
+
"janela--chart-formatted-value": result.values.map { |measured| query.format(measured) }.to_json,
|
|
30
|
+
"janela--chart-filters-value": query.filters_for(result.keys).to_json,
|
|
31
|
+
"janela--chart-fixed-height-value": (true if query.boxed?)
|
|
32
|
+
}) %>
|
|
33
|
+
<%= query.boxed? ? tag.div(canvas, class: "janela-chart-box janela-h-#{query.height}") : canvas %>
|
|
28
34
|
</figure>
|
|
29
35
|
<% else %>
|
|
30
36
|
<%= tag.table class: "janela-pane", aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) } do %>
|
|
31
37
|
<caption><%= query.title %></caption>
|
|
38
|
+
<%# A header row only when there are companions to name: two columns read
|
|
39
|
+
without one, and a table with none is what it always was (ADR 051). %>
|
|
40
|
+
<% if query.companion_columns.any? %>
|
|
41
|
+
<thead>
|
|
42
|
+
<tr>
|
|
43
|
+
<th scope="col"><%= query.dimension_header %></th>
|
|
44
|
+
<th scope="col"><%= query.measure_header %></th>
|
|
45
|
+
<% query.companion_columns.each do |column| %>
|
|
46
|
+
<%= tag.th column.header, scope: "col", class: ("janela-fact" if column.fact) %>
|
|
47
|
+
<% end %>
|
|
48
|
+
</tr>
|
|
49
|
+
</thead>
|
|
50
|
+
<% end %>
|
|
32
51
|
<tbody>
|
|
33
52
|
<% result.each do |label, measured| %>
|
|
34
53
|
<% click = query.click_data(label) if query.clickable? %>
|
|
@@ -42,6 +61,9 @@
|
|
|
42
61
|
<% end %>
|
|
43
62
|
</td>
|
|
44
63
|
<td><%= query.format(measured) %></td>
|
|
64
|
+
<% query.companion_columns.each do |column| %>
|
|
65
|
+
<%= tag.td column.cells[label], class: ("janela-fact" if column.fact) %>
|
|
66
|
+
<% end %>
|
|
45
67
|
</tr>
|
|
46
68
|
<% end %>
|
|
47
69
|
</tbody>
|
data/config/locales/en.yml
CHANGED
|
@@ -20,6 +20,9 @@ en:
|
|
|
20
20
|
granularity: Granularity
|
|
21
21
|
limit: Rows
|
|
22
22
|
span: Width
|
|
23
|
+
height: Height
|
|
24
|
+
prominence: Prominence
|
|
25
|
+
companions: Beside the label
|
|
23
26
|
title: Title
|
|
24
27
|
janela:
|
|
25
28
|
actions:
|
|
@@ -64,6 +67,22 @@ en:
|
|
|
64
67
|
text: Text
|
|
65
68
|
link_hint: "A path on this site, such as /orders. Makes the heading a link."
|
|
66
69
|
title_placeholder: Janela writes one if you leave this blank
|
|
70
|
+
companions:
|
|
71
|
+
measures: Measures
|
|
72
|
+
dimensions: Dimensions
|
|
73
|
+
hint: Up to three, drawn as columns in a table. A dimension shows a value only where every row of its group shares one.
|
|
74
|
+
prominences:
|
|
75
|
+
automatic: Automatic
|
|
76
|
+
"1": Footnote
|
|
77
|
+
"2": Normal
|
|
78
|
+
"3": Hero
|
|
79
|
+
heights:
|
|
80
|
+
automatic: Automatic
|
|
81
|
+
"1": Very short
|
|
82
|
+
"2": Short
|
|
83
|
+
"3": Medium
|
|
84
|
+
"4": Tall
|
|
85
|
+
"5": Very tall
|
|
67
86
|
renderers:
|
|
68
87
|
bar: Bar chart
|
|
69
88
|
doughnut: Doughnut chart
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddHeightToJanelaPanes < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# How tall a bar or line chart is drawn, one of five steps (ADR 047). Null
|
|
4
|
+
# is what every existing pane is: unset, and drawn exactly as before.
|
|
5
|
+
add_column :janela_panes, :height, :integer
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddProminenceToJanelaPanes < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# How prominent a single value is drawn, one of three steps (ADR 050).
|
|
4
|
+
# Null is what every existing pane is: unset, and drawn exactly as before.
|
|
5
|
+
add_column :janela_panes, :prominence, :integer
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
class AddCompanionsToJanelaPanes < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
# Measures and dimensions a table pane draws as columns beside its label
|
|
4
|
+
# (ADR 051), by the names the model declared them under. Null is what
|
|
5
|
+
# every existing pane is: none, and drawn exactly as before.
|
|
6
|
+
add_column :janela_panes, :companions, :json
|
|
7
|
+
end
|
|
8
|
+
end
|
data/docs/agents.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
Topics: agents, mcp, tools, authorisation, host-integration
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Giving an Agent Access to Janela
|
|
6
|
+
|
|
7
|
+
Frames and panes are rows (ADR 012), so an agent could always arrange a
|
|
8
|
+
dashboard by writing them from a console. This page is for giving it tools
|
|
9
|
+
instead: read a frame, read a pane's values, and, if you choose, add, change,
|
|
10
|
+
remove and move panes. The reasoning is in ADR 053.
|
|
11
|
+
|
|
12
|
+
The short version: Janela gives you the tool definitions as plain Ruby. You
|
|
13
|
+
build them with the answer to "what may this caller read", and you register
|
|
14
|
+
them with whatever MCP library your application already runs. Janela
|
|
15
|
+
registers nothing, detects nothing and serves nothing, because it cannot
|
|
16
|
+
know who is asking and your application can.
|
|
17
|
+
|
|
18
|
+
## Building the tools
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) })
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`scope:` is required. It takes a model class and returns a relation: the same
|
|
25
|
+
answer your `policy_scope` gives a Janela controller (ADR 032). Leave it out
|
|
26
|
+
and `Janela::Unscoped` is raised when the tools are built, because a tool with
|
|
27
|
+
no scope would read every row of every model. Every read the tools make passes
|
|
28
|
+
that relation, and frames and panes are found through it too, so a caller can
|
|
29
|
+
change only what it can see.
|
|
30
|
+
|
|
31
|
+
Build the tools where you know who is asking, which for an MCP server is when a
|
|
32
|
+
tool is called. They are cheap to construct.
|
|
33
|
+
|
|
34
|
+
## What they do
|
|
35
|
+
|
|
36
|
+
| Tool | Does | Needs `write: true` |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `describe_vocabulary` | The models, measures, dimensions, renderers and granularities the `janela` blocks declare. An agent should offer nothing else. | no |
|
|
39
|
+
| `list_frames` | The frames the scope returns: id, name, owner, pane count. | no |
|
|
40
|
+
| `get_frame` | One frame with its panes in position order. | no |
|
|
41
|
+
| `read_pane` | A pane's values, through the scope. Filters are Ransack predicates on declared dimensions, bounded as a reader's are (ADR 025). | no |
|
|
42
|
+
| `add_pane` | Add a pane to the end of a frame. | yes |
|
|
43
|
+
| `update_pane` | Change a pane. An invalid change leaves it as it was. | yes |
|
|
44
|
+
| `remove_pane` | Remove a pane and close the gap. | yes |
|
|
45
|
+
| `move_pane` | Move a pane one place up or down. | yes |
|
|
46
|
+
|
|
47
|
+
The write tools are off unless you build the tools with `write: true`. Their
|
|
48
|
+
inputs are the pane's own attributes, the ones the README
|
|
49
|
+
names, and nothing else: an attribute that is not a pane's is refused rather
|
|
50
|
+
than dropped. A pane Janela would refuse is refused with its own reason, for
|
|
51
|
+
example `Measure "nonsense" is not a measure of Order`.
|
|
52
|
+
|
|
53
|
+
There is no tool to create a frame or take a snapshot. Your code finds or makes
|
|
54
|
+
a frame with `Janela::Frame.for` (ADR 041), and a snapshot needs an owner and a
|
|
55
|
+
scope you have named (ADR 033, ADR 034).
|
|
56
|
+
|
|
57
|
+
## Registering them
|
|
58
|
+
|
|
59
|
+
`Janela::Tools.all` is the list of definitions, and needs no scope: each has a
|
|
60
|
+
`name`, a `description`, an `input_schema` (JSON Schema) and `read_only`.
|
|
61
|
+
`tools.call(name, arguments)` runs one and returns a Hash, or raises a
|
|
62
|
+
`Janela::Error` (`NotFound`, `BadRequest` or `Unscoped`) that an adapter reports
|
|
63
|
+
to the agent as the tool's own error.
|
|
64
|
+
|
|
65
|
+
For the official Ruby SDK, the `mcp` gem, an adapter looks like this. It is run
|
|
66
|
+
by Janela's own tests, so it cannot drift from the code. `server_context` is
|
|
67
|
+
whatever your server was built with, and is where you keep the caller; the
|
|
68
|
+
scope is built from it at the moment of the call.
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
def janela_server(write: false)
|
|
72
|
+
tools = Janela::Tools.all(write: write).map do |tool|
|
|
73
|
+
MCP::Tool.define(name: tool.name, description: tool.description, input_schema: tool.input_schema,
|
|
74
|
+
annotations: { read_only_hint: tool.read_only }) do |server_context:, **arguments|
|
|
75
|
+
# The scope is built per call, because who is asking is known only now.
|
|
76
|
+
scope = ->(model) { server_context[:scope].call(model) }
|
|
77
|
+
result = Janela::Tools.new(scope: scope, write: write).call(tool.name, arguments)
|
|
78
|
+
MCP::Tool::Response.new([ { type: "text", text: result.to_json } ])
|
|
79
|
+
rescue Janela::Error => error
|
|
80
|
+
MCP::Tool::Response.new([ { type: "text", text: error.message } ], error: true)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
MCP::Server.new(name: "janela", tools: tools, server_context: { scope: yield })
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`mcp` is not a dependency of Janela, and a host using another library writes the
|
|
89
|
+
same few lines against it.
|
|
90
|
+
|
|
91
|
+
## What Janela does not do
|
|
92
|
+
|
|
93
|
+
It does not serve MCP, detect a server, edit your configuration or install
|
|
94
|
+
anything. It holds no credentials and has no idea who your agent is. Whether an
|
|
95
|
+
agent may reach your MCP server at all is your server's authentication, which
|
|
96
|
+
Janela never sees. Everything it adds is the question it already asks: what may
|
|
97
|
+
this caller read?
|
data/docs/composing.md
CHANGED
|
@@ -23,12 +23,12 @@ with your own classes plus the hooks in [Theming Janela](theming).
|
|
|
23
23
|
| `janela_frame do ... end` | anywhere inside the block | the page needs a heading, text, links or a layout of its own |
|
|
24
24
|
| `janela_frame @frame` | none | the dashboard is data an analyst edits without a deploy |
|
|
25
25
|
|
|
26
|
-
A stored frame renders panes and
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
A stored frame renders panes, and a pane is either a query, a few words
|
|
27
|
+
the analyst writes (a heading, a sentence, a link), or a partial you
|
|
28
|
+
wrote and they place by name (ADR 039). What an analyst writes is
|
|
29
|
+
escaped, never markup, so a row cannot put arbitrary content on the page
|
|
30
|
+
(ADR 012). Anything richer than that, an icon, an image, a layout of your
|
|
31
|
+
own, goes in the page around the frame, or use the block form.
|
|
32
32
|
|
|
33
33
|
```erb
|
|
34
34
|
<h2>Orders</h2>
|
|
@@ -58,9 +58,9 @@ same classes a stored frame gets from its integers:
|
|
|
58
58
|
|
|
59
59
|
Every direct child is a grid item, your own elements included, so a
|
|
60
60
|
heading can take a whole row and a text box can sit between two panes.
|
|
61
|
-
`janela_pane` takes no class of its own
|
|
62
|
-
(
|
|
63
|
-
|
|
61
|
+
`janela_pane` takes no class of its own, and will not: arranging panes
|
|
62
|
+
beyond the shipped grid is the host's (see the roadmap). So wrap a pane
|
|
63
|
+
to span it:
|
|
64
64
|
|
|
65
65
|
```erb
|
|
66
66
|
<div class="janela-span-2"><%= janela_pane Order, :revenue, by: :status, as: :bar %></div>
|
|
@@ -195,11 +195,11 @@ and number, so the figure needs a `<div>` rather than a `<p>` around it,
|
|
|
195
195
|
and a line of CSS to put the numbers side by side. Without it they
|
|
196
196
|
stack.
|
|
197
197
|
|
|
198
|
-
A done out of total figure
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
198
|
+
A done out of total figure over a yes or no column is one pane: a
|
|
199
|
+
`ratio:` measure is the share of rows where a boolean is true (ADR 038).
|
|
200
|
+
When the condition is not a column, a measure has no condition of its own,
|
|
201
|
+
so there is no pane for the done half; compute it as the progress bar
|
|
202
|
+
below does.
|
|
203
203
|
|
|
204
204
|
### A progress bar
|
|
205
205
|
|