Part 5 - The Dashboard
This is Part 5 of the Smart Greenhouse tutorial. You put the bed in the browser - learning how a cell serves a web page through the gateway.
It continues where Part 4 left off: all four cells are deployed and the greenhouse is watering itself.
Step 7 - The Dashboard
Everything the greenhouse knows still lives inside the swarm - and browsers do not speak what the swarm speaks, they speak HTTP. The bridge between the two worlds is the myrmic gateway: an HTTP server that cells register their routes on. To put the bed on a web page we build one more cell, following the same pattern as the pump: an adapter. The pump adapts a motor; the dashboard, along with the gateway, adapts the browser.
One thing to be clear about up front: the dashboard cell does not run the gateway. The gateway is its own process (it gets Terminal 4 in a moment); the cell registers its routes on it and fills the store the gateway serves from. Tear the cell down and its routes and files disappear with it - the gateway keeps running.
Scaffold the cell and add the serde line to dashboard/Cargo.toml, as in Part 3:
myrmic new dashboard
serde = { version = "1", default-features = false, features = ["alloc", "derive"] }
Replace the content of dashboard/src/lib.rs with:
//! Dashboard adapter: serves the greenhouse web page through the gateway.
//! It mirrors the grow-bed's `bed_state` into a small JSON snapshot that the
//! page polls. It owns no truth and makes no decisions.
#![no_std]
use myrmic_sdk::{Metadata, Result, format, gateway};
/// Payload of the grow-bed's `bed_state` event.
#[derive(serde::Serialize, serde::Deserialize, myrmic_sdk::Message)]
struct BedState {
moisture: f32,
pump_on: bool,
target_low: f32,
target_high: f32,
}
#[myrmic_sdk::init]
fn init(md: Metadata) -> Result<()> {
gateway::assets(md.id).put("/index.html", include_bytes!("../assets/index.html"))?;
gateway::mount("/greenhouse")
.index("/index.html")
.bind()
.map_err(<&'static str>::from)?;
Ok(())
}
/// Every `bed_state` update becomes a fresh `latest.json` for the page to poll.
#[myrmic_sdk::evt]
fn bed_state(md: Metadata, bed: BedState) -> Result<()> {
let json = format!(
r#"{{"moisture":{:.1},"pump_on":{},"target_low":{},"target_high":{}}}"#,
bed.moisture, bed.pump_on, bed.target_low, bed.target_high
);
gateway::assets(md.id).put("/latest.json", json.as_bytes())
}
Reading it top to bottom:
- The cell declares its own
BedStatestruct matching the event's JSON fields - cells share wire formats, not Rust types. Each side owns its definition. gateway::assets(md.id)- the cell's private blob store for static files. Anything written there is served by the gateway once the cell mounts a route. At init the cell uploads the web page into it.gateway::mount("/greenhouse")- claims the URL prefix on the gateway..index("/index.html")serves that file at the mount root and enables static serving for everything else in the store. See the gateway reference for the full mount API (APIs, WebSockets, fallbacks).- The
bed_statehandler is the whole application logic of this cell: every announcement from the asset is rewritten aslatest.jsonin the store. The file is a read model - a derived copy optimized for display. The truth stays in the grow-bed; delete this cell and no information is lost.
Now create the web page at dashboard/assets/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Smart Greenhouse</title>
<style>
body { font-family: sans-serif; max-width: 28rem; margin: 3rem auto; }
h1 { font-size: 1.4rem; }
.value { font-size: 3rem; font-variant-numeric: tabular-nums; }
meter { width: 100%; height: 1.5rem; }
.row { margin: 0.75rem 0; }
#pump.on { color: #1a7f37; font-weight: bold; }
</style>
</head>
<body>
<h1>Smart Greenhouse - Grow Bed 1</h1>
<div class="row">Soil moisture: <span class="value" id="moisture">--</span>%</div>
<div class="row"><meter id="bar" min="0" max="100" value="0"></meter></div>
<div class="row">Pump: <span id="pump">--</span></div>
<div class="row">Target range: <span id="target">--</span></div>
<script>
async function refresh() {
const res = await fetch('/greenhouse/latest.json', { cache: 'no-store' });
if (!res.ok) return;
const data = await res.json();
document.getElementById('moisture').textContent = data.moisture.toFixed(1);
document.getElementById('bar').value = data.moisture;
const pump = document.getElementById('pump');
pump.textContent = data.pump_on ? 'RUNNING' : 'off';
pump.className = data.pump_on ? 'on' : '';
document.getElementById('target').textContent =
data.target_low + '% - ' + data.target_high + '%';
}
async function main() {
// Poll the gateway forever: one HTTP call every 2 seconds.
for (;;) {
try { await refresh(); } catch (e) { /* gateway restarting */ }
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
main();
</script>
</body>
</html>
The page is deliberately plain: a loop that fetches latest.json every 2 seconds and writes the values into the DOM. No framework, no build step.
One thing to know before deploying: include_bytes! bakes the page into the Wasm binary, and a cell's memory is a fixed budget declared in its Cargo.toml. Our small page still fits the default budget - but if yours grows (more markup, some CSS, an embedded image), the build stops with:
rust-lld: error: initial memory too small, <N> bytes needed
If that happens, give the cell more room in dashboard/Cargo.toml:
[package.metadata.myrmic]
heap_size = 65_536
initial_memory = 262_144
max_memory = 262_144
See Cell and application configuration for what these values mean. Now deploy:
myrmic deploy dashboard
Time to open the doors. The gateway is the swarm's HTTP entry point - start it in Terminal 4 and leave it running:
myrmic gateway
The default port is 8080 (--port changes it). Open your browser at:
http://localhost:8080/greenhouse/
There is the bed: the moisture value updating as the mock's weather swings, the target range from the asset, the pump state. And the page is not just showing the weather - watch long enough and you will see Part 4's agent at work: the pump indicator flipping to RUNNING at the low target and off again at the high one, with nobody at the keyboard.
Follow the data on its round trip: sensor → moisture event → grow-bed → bed_state event → dashboard → latest.json in its store → gateway → HTTP → browser. No cell ever runs a web server; the dashboard just writes files and the gateway serves them.
What Have You Learned
- The gateway is the swarm's HTTP entry point. Cells do not run it - they register routes on it, and the routes and files live and die with the cell.
gateway::assets(...)is a per-cell blob store; whatever a cell writes there, the gateway serves over HTTP once the cell mounts a route with.index(...)or.assets().- A dashboard keeps a read model: a derived copy of the asset's state, optimized for display. The truth stays in the asset - deleting the dashboard loses nothing.
include_bytes!bakes files into the Wasm binary - and larger binaries need more memory, configured in[package.metadata.myrmic].
Next Step
The greenhouse is complete: measured, remembered, decided, displayed. In Part 6 - The Finale you play with the running system, tear it down, and bring it all back with a single command.