Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The dashboard

The engine answers what a dashboard asks (Progress and health); magic_sync_dashboard, on npm, draws it in Vue 3. In between are your handlers: the dashboard fetches nothing, and the engine serves no HTTP. That is on purpose — who may see which replicas is your system’s question, asked behind your login, and the engine only takes the answer as a scope.

The handlers

You write the reads, in whatever your server is built with. The shop’s, in axum (examples/shop/server/src/main.rs):

// What the dashboard reads: the engine's answers, as they are. A shop with a
// login would ask them over the registers the caller may see
// (`Scope::Replicas`); this one has none, so every register. Each read is one
// snapshot of its own, so each takes a connection, not a transaction.

async fn overview(State(app): State<App>) -> Result<Json<Overview>, Failure> {
    let mut conn = app.pool.acquire().await?;
    let overview = app
        .server
        .engine()
        .overview(&mut conn, Scope::All, 20)
        .await?;
    Ok(Json(overview))
}

#[derive(Deserialize)]
struct Page {
    state: Option<ReplicaState>,
    #[serde(default)]
    offset: u32,
}

async fn listing(
    State(app): State<App>,
    Query(page): Query<Page>,
) -> Result<Json<Listing>, Failure> {
    let mut conn = app.pool.acquire().await?;
    let listing = app
        .server
        .engine()
        .listing(
            &mut conn,
            Scope::All,
            page.state,
            magic_sync::status::Page::new(page.offset, 20),
        )
        .await?;
    Ok(Json(listing))
}

async fn progress(
    State(app): State<App>,
    Path(register): Path<Uuid>,
) -> Result<Json<Progress>, Failure> {
    let mut conn = app.pool.acquire().await?;
    let progress = app
        .server
        .engine()
        .progress(&mut conn, Scope::Replicas(&[register]))
        .await?;
    Ok(Json(progress))
}

async fn row(
    State(app): State<App>,
    Path((table, key)): Path<(String, String)>,
) -> Result<Json<RowProgress>, Failure> {
    let mut conn = app.pool.acquire().await?;
    let places = app
        .server
        .engine()
        .row_progress(&mut conn, Scope::All, &table, &key)
        .await
        .map_err(|e| {
            if e.is_transient() {
                Failure::from(e)
            } else {
                // A table that does not travel, a key that is not one: the
                // caller's.
                Failure(StatusCode::BAD_REQUEST, e.to_string())
            }
        })?;
    Ok(Json(places))
}
async fn wishes(State(app): State<App>) -> Result<Json<WishListing>, Failure> {
    let mut conn = app.pool.acquire().await?;
    let wishes = app
        .server
        .engine()
        .wishes(
            &mut conn,
            Scope::All,
            None,
            magic_sync::status::Page::new(0, 50),
        )
        .await?;
    Ok(Json(wishes))
}

The shop has no login, so it asks over every register (Scope::All). A system with one answers for the caller instead: a customer’s partition (Scope::Partition), or the replicas this user may see (Scope::Replicas(&ids)). Each read takes a connection and reads one snapshot of its own. The answers serialize as they are; return them untouched, and the dashboard of the same version reads them — an answer of another contract version it refuses, saying so, rather than draw it wrong.

What happens: the overview reads only what the engine records, so it answers in milliseconds for hundreds of replicas that are caught up — a fifth of a second at worst, while a whole fleet has rows on their way — and the page can ask every few seconds. It counts every replica but lists only the worst twenty; the listing pages through the rest, in one state or all. Progress counts what one replica should hold: ask it for the one being looked at, not for the fleet.

The page

pnpm add magic_sync_dashboard

You write the fetching, and your names for what the engine knows by id. The shop’s (examples/shop/web/src/App.vue), where api is a fetch of each handler above (examples/shop/web/src/api.ts):

import { computed, onUnmounted, ref, watch } from 'vue'
import {
  RowPlaces,
  SyncDashboard,
  type Listing,
  type Overview,
  type Progress,
  type ReplicaState,
  type RowProgress,
} from 'magic_sync_dashboard'
import { api, type FooterTwin, type Product, type ShopState } from './api'

const state = ref<ShopState | null>(null)
const overview = ref<Overview | null>(null)
const selected = ref<string | null>(null)
const progress = ref<Progress | null>(null)
// The replicas listed, a page at a time, in the state picked.
const filter = ref<ReplicaState | null>(null)
const offset = ref(0)
const listing = ref<Listing | null>(null)
// The product whose registers are shown, and where it is on each.
const holding = ref<string | null>(null)
const places = ref<RowProgress | null>(null)
const error = ref('')

// The dashboard's names for what the engine knows by id.
const names = computed(() => Object.fromEntries((state.value?.registers ?? []).map((r) => [r.id, r.name])))
const labels = { products: 'Products', receipts: 'Receipts' }

async function refresh() {
  try {
    ;[state.value, overview.value, listing.value] = await Promise.all([
      api.state(),
      api.overview(),
      api.listing(filter.value, offset.value),
    ])
    progress.value = selected.value ? await api.progress(selected.value) : null
    places.value = holding.value ? await api.row('products', holding.value) : null
    error.value = ''
  } catch (e) {
    error.value = String(e)
  }
}
refresh()
const timer = setInterval(refresh, 1000)
onUnmounted(() => clearInterval(timer))
watch([selected, holding, filter, offset], refresh)
        <!-- A register unplugged is "not answering" after 30 s, not 15 min: it is a demo. -->
        <SyncDashboard
          v-model:selected="selected"
          v-model:filter="filter"
          v-model:offset="offset"
          :overview="overview"
          :listing="listing"
          :progress="progress"
          :page-size="20"
          :names="names"
          :labels="labels"
          :silent-after="30_000"
        />

and its styles, once, where the app starts (examples/shop/web/src/main.ts):

import 'magic_sync_dashboard/styles.css'

What happens: a banner says in one line where things stand — green, everything is in sync, most of the time; amber while changes are on their way or a replica is out of reach; red when something waits for a fix — then the replicas, worst first, the tables, and the problems, each with its reason. Picking a state, or turning a page, sets filter or offset; you fetch that page of the listing. Without a listing, the dashboard shows the overview’s worst and says how many there are in all. Clicking a replica sets selected; you fetch its progress, and it shows table by table. How long ago something happened is measured from the server’s now in the answer, moved on by the time passed in the page — the browser’s clock is never compared with the server’s.

Where one row is

“Which registers hold this product?” is asked next to the product, in your own listing — not on the dashboard, which knows rows only by key. The shop puts a who holds it? link on each product: it asks the row handler with the product’s id (places, above), and shows the answer with RowPlaces, under the product:

            <tr v-if="holding === p.id">
              <td colspan="3"><RowPlaces :places="places" :names="names" /></td>
            </tr>

What happens: each register, and where the row stands on it — holds it, holds an older version, on its way, letting it go, or set aside. In a system with many listings, the same link on each listing of a table that travels answers “which units hold this operator”, “which hold this order”.

Wishes

A replica’s line says how many wishes of its settings it has not carried out, is waiting to, refused, or found changed there first (Settings and wishes); a replica with one it has not answered is catching up. One waiting is answered — the replica tries it again by itself, and its reason says what it waits for — so it does not keep the replica from synced. Engine::wishes lists them — those that need attention first — for WishList to show, each with its replica’s answer and why.

Its look and its words

Its look is CSS variables (--msd-accent, --msd-ok, …) you may set to your own tokens on any ancestor; the defaults keep every text at a contrast of 4.5:1 or more, which a test of the stylesheet holds them to. Its words are English or Spanish, following your vue-i18n locale, each a whole sentence whose counts take the locale’s plural forms. A system with a design system of its own skips the ready-made component and draws the same answers with its own parts, through useSyncDashboard and the sentence functions of magic_sync_dashboard/core: the package’s README says how.

One version, both sides

The dashboard’s types are generated from the engine’s (status.rs’s test writes dashboard/src/core/contract/types.ts), and the engine’s own test writes what it answers, every state and every kind of problem, into the dashboard’s fixtures, which its type-check holds field for field to those types. A field renamed on one side fails the other’s build. Release and upgrade the crate and the package together: they carry the same version.