datagrout-panels
The DataGrout Smart Panel model: declarative UI
stored as logic-cell facts, parsed into a renderer-agnostic Panel tree.
A panel is created on DataGrout by calling the gateway's smart_panel.publish
tool:
which compiles into Prolog facts in the _panels namespace of a logic cell —
the same knowledge base the rest of your rules and data live in:
panel(revenue_chart, bar_chart, my_app).
panel_prop(revenue_chart, title, 'Revenue by Month').
panel_source(revenue_chart, my_app, 'monthly_revenue(Month, Amt)').
panel/3 is the id, the kind and the owning namespace; panel_prop/3 is one
fact per config key; panel_source/3 names a namespace and a Prolog goal
whose solutions are the panel's rows — one row per solution, Month and Amt
as the columns. (A panel with fixed rows instead carries panel_data/2.)
Nothing is copied into the panel and the goal re-runs on every read, so a panel is derived rather than stored.
That is what the "smart" is doing. The goal can call rules, not just match
stored facts, so a panel over at_risk(Deal) shows whatever satisfies that
rule right now: change the rule in the cell and every panel built on it
changes, with no panel edited and no cache to invalidate. Because the
definitions are themselves facts, an agent holding smart_panel.publish can
build a dashboard as an outcome of its reasoning, and logic.query can audit
what exists. Form fields go further — they declare dependencies, triggers and
emits, and a field's goal can invoke a tool and replace the field's own value.
That cascade runs in the cell, not in the client: on submit, DataGrout binds the
fields into the panel's goal (or into the + inputs of a rule published with
reactor.expose) and runs it under the cell's sandbox.
This crate turns those facts into something a renderer can walk.
This crate parses those facts and stops. Rendering lives in separate crates —
datagrout-panels-egui
(native GUI) and
datagrout-panels-mcp
(MCP Apps) — so a consumer that only transpiles never links a GUI toolkit.
Where panels live, and how you read them
A logic cell is scoped to one account and one hub server, so the panels you
can read are those published through the server you connected to. Within
_panels, each panel's own namespace (my_app above) groups it with its
siblings.
Composites are facts too, not nesting: a dashboard is published as
kind: "dashboard" with each child naming it in props.parent, and a form
as kind: "form" with a fields array whose entries may declare inputs, a
trigger and an emit. The parser inverts those edges into children and
fields.
Reading panels back is a single smart_panel.list call, whose response is what
Panel::all_from_list parses. There is no transport here, and this crate
creates nothing: bring an MCP client such as
conduit-sdk, or any MCP-speaking
host, and hand the response over.
Kinds and props are a fixed vocabulary — the display kinds (bar_chart,
table, metric, gauge, dashboard, …), the form kinds (form,
text_input, dropdown, button, …), and props like title, parent,
slot, columns and published. The full list, with prop types, is in
SPEC.md.
No account yet? The parser takes plain JSON, so a hand-written list response renders like a real one:
use Panel;
use json;
let panels = all_from_list;
assert_eq!;
assert_eq!;
Two ways in
From smart_panel.list — the intended path. One gateway call returns every
panel with its props, a row preview and its parts already resolved:
use Panel;
// `response` is the JSON the `smart_panel.list` tool returned.
let panels: = all_from_list;
for p in &panels
From raw facts — when you need full row sets or field metadata. Run each
goal in goals through logic.query and hand the solution rows over:
use ;
let facts = PanelFacts ;
let panels = all_from_facts;
The model
Panel { id, kind, namespace, props, rows, source, fields, children, published }.
- Kinds mirror the gateway's publish tool: charts,
table,metric,gauge,markdown,dashboard, and the form kinds (form,text_input,dropdown,button, …). A kind this crate predates parses asPanelKind::Unknown(String)so a renderer can draw a placeholder instead of failing. - Composition is by the child's
parentprop. A dashboard's children and a form's fields are their ownpanel/3facts naming the container; the parser inverts that edge intochildrenandfields, and parts do not appear at the top level. - Props are typed.
columnsis a list,publisheda boolean.propsis aBTreeMap<String, serde_json::Value>with coercing accessors:prop_str,prop_list,prop_bool,prop_f64, plustitle(),description(),columns(),parent(),slot(). - Rows are
Vec<Vec<Value>>afternormalize_rows, which accepts the list-of-lists snapshot form and the object-per-solution formlogic.queryreturns. - Form interaction is typed too. A
Fieldcarries its dependency edges (inputs), aFieldTrigger { trigger_type: TriggerType, event: TriggerEvent }and aFieldEmit— all enums with the same totalparse/as_str/Unknowntreatment as kinds.field.fires_on(&TriggerEvent::Change)answers whether a field asks to fire on an event; honouringTriggerType::Onceis the host's job, since only the host knows what has already run. - Identity is
(namespace, id); duplicate registrations from a republish collapse to one.
What a fetch layer has to know
This crate does not fetch. Whatever does should expect:
- An undefined predicate is an error from
logic.query, not an empty result. Treat it as empty. - Results over roughly 48 KB come back as a
cache_refrather than inline;prism.paginatepages them, as solution rows. panel_data(Id, Rows)is an ordered snapshot;panel_sourcerows come back as objects whose key order is not the query's variable order. Prefer the snapshot when both exist.
The full contract, including renderer conformance, is in
SPEC.md.
Security
Panel facts may have been asserted by an agent. Treat every prop and row as untrusted text; the renderers escape accordingly.
License
MIT OR Apache-2.0, at your option.