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.

science Preview. The one-click 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.
15Entities
1Binder screen
0Lines of UI code
~60sLive countdown
Launch Control running in Vantage UI

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 ConfirmedGo 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.

rocket_launch Launch the hub
apartmentAgency provider · manufacturerbelongs-to
location_onPad → Location two hops deepbelongs-to
rocketRocket config launcher familybelongs-to
inventory_2Payloads via payload flightshas-many
groupCrew astronauts aboardhas-many
flight_landLandings booster → landpadhas-many
functions Stats are computed, never stored. Launches per agency, payload mass per flight, success and failure tallies — all evaluated on read with aggregate expressions, so there's no denormalized column to keep in sync. You'll see one in Rust two chapters down.

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.

dnsdatasource/local.yaml
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.

tabletable/launches.yaml
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 }
Relations are the UI. Model the data once; the clickable cells and the master/detail tabs both come from it.

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.

grid_viewpage/launches.yaml
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
Live without websockets. The probe is microseconds of Rhai over cached rows; the simulator bumps 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.

dynamic_formaction/new-launch.yaml
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.

functionsserver/src/model/agency.rs
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.

apiserver/src/rest.rs
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 &params {                    // 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 })))
}
The facade is the contract. One generic handler serves all fifteen entities — paging, filtering, ordering and LL2 nesting, generically.

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.

dnsFlaky serverlatency + random 503s
cachedDiorama cacheholds last-good data; never blanks
grid_viewLaunch boardsmooth, virtualised, self-refreshing

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.

boltserver/src/flaky.rs
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.

timerserver/src/sim/countdown.rs
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
}
warning Crank it up. Run 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.

terminalRun the whole thing locally
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.