Phase 01 / Liftoff
Launch Control
A live mission-control console for global spaceflight. Fifteen related entities, real-time launch countdowns, and a deliberately flaky API the interface rides straight through — and the entire thing is a folder of YAML talking to a real Rust server.
This page is the whole story, top to bottom. Installing the app is just the first thirty seconds of it — stay on, and you'll see exactly how the console is declared, how the backend is modeled, and why it doesn't flinch when the network does.
vantage://
installer is live in Vantage UI today. The hosted server it points at is
coming online shortly; until then, run the bundled server locally — it's
three commands, in Your mission.
Phase 02 / Mission briefing
What you're looking at
Launch Control mirrors Launch Library 2, the public dataset that tracks spaceflight across every agency on Earth — rockets, pads, payloads, crews and the missions that tie them together. Real domain, real shape, real mess: IDs that are sometimes integers and sometimes UUIDs, objects nested two levels deep, stats that have to be counted rather than stored.
On screen it's a single launch board. Each row is a flight; select one and the panel below fills with its payloads, its crew and its landings. From there you keep going — open a launch's provider to see every mission they've flown, follow a pad to its location, trace a booster's landing to the pad it touched down on. One screen, fifteen entities, drilling all the way down.
And it's not a static snapshot. Hit "New simulated launch" and the server schedules a mission at T-1min, then ticks it through a real sixty-second countdown — To Be Confirmed → Go for Launch → outcome — while the board updates underneath you. No reload, no refresh button. You watch a rocket fly.
Phase 03 / Flight data
Launch is the hub
Everything in the model orbits a single entity. A launch belongs to a provider, flies a rocket from a pad, carries payloads and crew, and ends in one or more landings. Model those relationships once and the entire navigation of the app falls out of them for free.
Phase 04 / The console
The whole client is YAML
There is no frontend code in this example. The console is a handful of declarative files: one datasource, a table per entity, a page, and an action. Here's the path from "an API exists" to "a drillable board", file by file.
1 · Point at the server, once. The
api-client datasource learns the LL2 envelope — the array
lives under results, the total under count — and
pushes filters server-side. Because this server honours every filter param,
one datasource drives all fifteen tables.
type: api-client
url: "http://127.0.0.1:8080"
response_key: results # the array inside the envelope
total_key: count # so grids size their scrollbar and load lazily
pagination:
page_param: offset
limit_param: limit
offset_based: true
filter_strategy: query # this server honours every ?field=value filter
2 · Declare a table — relations included.
Fetch ?mode=detailed so belongs-to objects come back nested,
then surface their leaves as dotted columns. A hidden foreign-key column
carries references:, which turns its cell into a drill link —
and the same references: generate the binder's detail tabs.
datasource: local
table: "launches/?mode=detailed&ordering=-last_updated"
columns:
- { name: name, type: string, flags: [title, searchable] }
- { name: status.name, type: string, flags: [label] } # colored tag
- { name: lsp_id, type: string, flags: [hidden], references: agencies }
- { name: launch_service_provider.name, type: string } # nested leaf
- { name: pad.location.name, type: string } # two hops deep
- { name: payload_count, type: int } # computed server-side, not stored
- { name: total_payload_mass, type: float }
references: # these become the binder's detail tabs
payloads: { table: payload_flights, kind: has_many, foreign_key: launch__id }
crew: { table: launch_crew, kind: has_many, foreign_key: launch__id }
landings: { table: landings, kind: has_many, foreign_key: launch__id }
3 · Make it a live Binder. The page is
a binder layout — grid on top, auto-derived detail tabs below.
A toolbar action triggers a simulated launch; a cheap refresh_probe
folds the cached rows for a change signal, so a full refresh only happens
when the simulator actually moves something.
title: Launches
layout: binder
elements:
- kind: crud
table: launches
toolbar:
- label: "New simulated launch"
icon: Play
action: |
let r = actions.new_launch();
actions.submit_launch(r.lsp_id, r.pad_id, r.rocket_configuration_id, r.name);
params:
refresh_interval: 5
# Cheap change probe: count + newest last_updated, over the cached rows.
refresh_probe: |
let rows = page_table().list(); let latest = "";
for r in rows { let u = r.last_updated; if u != () && u > latest { latest = u; } }
"" + rows.len() + "@" + latest
last_updated; the grid notices and refreshes.4 · An action that knows the model. The
"new launch" dialog is a table-derived form, so its foreign-key fields
render as real dropdowns. One of them is dependent: pick a
provider and the rocket list narrows to that agency's configurations,
driven entirely by depends_on + filter_column.
kind: form
form:
table: launches
fields:
- { name: lsp_id, label: "Launch provider", widget: dropdown }
- name: rocket_configuration_id
label: "Rocket configuration"
widget: dropdown
depends_on: lsp_id # dependent dropdown:
filter_column: manufacturer_id # narrow to the chosen agency's rockets
- { name: pad_id, label: "Pad", widget: dropdown }
- { name: name, label: "Mission name", required: false }
Phase 05 / The backend
The server is a Vantage app too
Here's the part most demos hand-wave. The API isn't a fixture server — it's a real Rust backend built on the same Vantage framework the UI consumes. SQLite tables become typed entities with relations and computed expressions, every entity collapses into one universal facade, and a single Axum handler serves the lot as a familiar REST API.
Computed stats, never stored. An agency has many
launches; its tallies are with_expression aggregates —
correlated subqueries the database evaluates on read. Note that
query_launches() is just the with_many
relation reused as a subquery: model the relation once, count over it
everywhere.
Table::new("agencies", db)
.with_id_column("id")
.with_column_of::<String>("name")
.with_one("type", "type_id", AgencyType::table)
.with_many("launches", "lsp_id", Launch::table)
// aggregates over the related launches — computed on read, not stored:
.with_expression("total_launch_count", |t| t.query_launches().get_count_query())
.with_expression("successful_launches", |t| t.query_launches().count_successful())
.with_expression("failed_launches", |t| t.query_launches().count_failed())
.with_expression("pending_launches", |t| t.query_launches().count_pending())
One handler, every table. Each entity
becomes a backend-blind Vista — the exact same read surface the
UI's api-client speaks. A single route reads any table through it, mapping
leftover query params to server-side filters. "Consume an API" and "serve an
API" turn out to be the same shapes, mirrored.
async fn list(
State(state): State<AppState>,
Path(table): Path<String>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Value>, ApiError> {
let mut vista = vista_for(&state.db, &table)?; // table name -> Vista
for (key, value) in ¶ms { // leftover ?field=value
if is_reserved(key) { continue; } // becomes a filter
let column = to_column(key); // LL2 `lsp__id` -> `lsp_id`
if vista.get_column(&column).is_some() {
let _ = vista.add_condition_eq(column, Cbor::Text(value.clone()));
}
}
let count = vista.get_count().await?;
let rows = vista.fetch_window(offset, limit).await?;
Ok(Json(json!({ "count": count, "next": null, "results": results })))
}
Phase 06 / Turbulence
A flaky API, a calm UI
The server is built to misbehave — that's the test. Every read is delayed
by 150–1200ms and, about a tenth of the time, answered with a
503 instead of data. Meanwhile the simulator is mutating
rows out from under the grid. The same data path that survives this demo
is the one that survives a real network.
The flakiness is honest middleware — random latency, then a probabilistic failure. Crucially it wraps only the read API; the trigger endpoint that starts a mission is left reliable, because writes must land.
pub async fn middleware(State(state): State<AppState>, req: Request, next: Next) -> Response {
let cfg = state.flaky;
let (delay_ms, fail) = { // decide before any await
let mut rng = rand::thread_rng();
let delay = rng.gen_range(cfg.latency_min_ms..=cfg.latency_max_ms);
(delay, rng.gen_bool(cfg.error_rate.clamp(0.0, 1.0)))
};
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
if fail {
return (StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": "injected flaky failure" }))).into_response();
}
next.run(req).await
}
The countdown is a plan, then a playback.
A simulator phase is a pure function: it returns a list of timed events; the
engine sorts them and plays them in real time. Each event stamps
last_updated — which the UI's refresh probe catches — so a
sixty-second timeline in Rust becomes a live countdown on screen.
fn plan(&self, ctx: &MissionContext, _rng: &mut StdRng) -> Vec<TimedEvent> {
let mut events = Vec::new();
// T-1min: schedule & confirm (status 8 = "To Be Confirmed")
events.push(timed(Duration::ZERO, "T-1min", |l| { l.net = net_in_60s; l.status_id = "8"; }));
// T-30s: a mid-countdown update — proves timed playback + live refresh
events.push(timed(Duration::from_secs(30), "T-30s", |l| { l.probability = Some(95); }));
// T-0: go for launch (status 1 = "Go for Launch")
events.push(timed(Duration::from_secs(60), "Go", |l| { l.status_id = "1"; }));
events
}
serve --error-rate 0.3
to fail a third of all reads and watch the grid stay put. The non-blanking
refresh is the whole reason this example exists.
Phase 07 / Your mission
Clone it, then make it yours
Both halves are open source and small enough to read in an afternoon — the YAML inventory and the Rust server sit side by side. Fork it as the starting point for your own backend-and-console, or just run it locally to poke at the live countdown.
git clone https://github.com/romaninsh/vantage-ui-examples
cd vantage-ui-examples/apps/launch-control
cargo run -p launch-control-server -- seed # seed SQLite from fixtures (once)
cargo run -p launch-control-server -- serve # the flaky API on :8080
# then, from the vantage-ui repo, point the app at the inventory:
cargo run -p vantage-ui -- --config ../vantage-ui-examples/apps/launch-control/inventory
Ready for launch
Install the app and open the example in one click, or dig straight into the source.