Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Deploy to GitHub Pages

on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install Rust + wasm target
run: |
rustup toolchain install stable --profile minimal
rustup target add wasm32-unknown-unknown

- uses: Swatinem/rust-cache@v2

- name: Install Trunk
uses: jetli/trunk-action@v0.5.0
with:
version: latest

- name: Build
run: trunk build --release --public-url /raftscope/

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: dist

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
.claude/
target/
dist/
docs/superpowers/
6 changes: 0 additions & 6 deletions .gitmodules

This file was deleted.

40 changes: 25 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,38 +4,48 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## What this is

A browser-based visualization of the Raft consensus algorithm (inspired by "The Secret Lives of Data"). Pure static site: vanilla JS + jQuery + Bootstrap 3, rendered as a live-updating SVG. No build step, no package manager, no test suite, no server.
A browser-based visualization of the Raft consensus algorithm (inspired by "The Secret Lives of Data"). Originally vanilla JS + jQuery + Bootstrap 3; now a full **Rust → WebAssembly** rewrite that renders a live-updating SVG via raw `web-sys` DOM calls (no UI framework). Bootstrap 3.1.1 CSS and `style.css` are kept for visual identity; all JavaScript dependencies (jQuery, Bootstrap JS, bootstrap-slider, bootstrap-contextmenu) are gone.

## Running it

Open `index.html` in a browser. That's it. Scripts load in dependency order (see `index.html`): `util.js` → `raft.js` → `state.js` → `script.js`.
This is a Trunk-bundled WASM app, not a static-open-in-browser site.

Dependencies are git submodules. After cloning:
```
git submodule update --init --recursive
rustup target add wasm32-unknown-unknown
cargo install trunk # or use a prebuilt trunk binary

trunk serve # dev server with hot reload
trunk build --release # production bundle into dist/
```
This pulls `bootstrap-slider` and `bootstrap-contextmenu`. `jquery/` and `bootstrap-3.1.1/` are vendored directly.

Code style is enforced informally via jshint directives at the top of each file (`'use strict'`, browser/jquery globals). There is no linter wired up to run.
`index.html` is the Trunk entry: SVG skeleton (defs/markers, `#ring`, `#pause`, `#messages`, `#servers`, `.logs`), native range inputs (`#time`, `#speed`), modals (`#modal-details`, `#modal-help`), `#context-menu`, and `<link data-trunk rel="rust" />` to pull in the WASM. Bootstrap CSS and `style.css` are linked both via `data-trunk rel="copy-*"` (for the bundle) and plain `<link>` (for dev).

`cargo test` runs the pure protocol/state layers natively (no browser needed) — see the cfg-gated `util::random`.

## Architecture

Three layers, deliberately separated:
Three layers, deliberately separated (ports of the original `raft.js`/`state.js`/`script.js`):

1. **`raft.js`** — the Raft protocol logic, pure and UI-agnostic. Operates on a `model` (`{servers, messages, time}`). All protocol behavior lives in `raft.rules.*` (the periodically-applied state-machine rules: `startNewElection`, `becomeLeader`, `sendRequestVote`, `sendAppendEntries`, `advanceCommitIndex`) and the message handlers. `raft.update(model)` is the single tick: it applies every rule to every server, then delivers any messages whose `recvTime <= model.time`. User-triggered actions (`raft.stop`, `resume`, `restart`, `timeout`, `clientRequest`, `drop`, plus demo helpers like `setupLogReplicationScenario`) also live here.
1. **`src/raft.rs`** — the Raft protocol logic, pure Rust, no `web-sys`. Operates on a `Model { time, servers, messages }`. Protocol behavior lives in the periodically-applied state-machine rules (`start_new_election`, `become_leader`, `send_request_vote`, `send_append_entries`, `advance_commit_index`) and the message handlers. `Model::update(&mut self)` is the single tick: it applies every rule to every server, then delivers any messages whose `recv_time <= time`. User-triggered actions (`stop`, `resume`, `resume_all`, `restart`, `timeout`, `client_request`, `drop`, plus demo helpers like `setup_log_replication_scenario`) also live here. Ships `#[cfg(test)] mod tests`.

2. **`state.js`** (`makeState`) — time-travel layer. Wraps the model in a checkpoint history so the timeline slider can scrub backward (`rewind`) and forward (`advance`/`seek`). `fork()` discards the future from the current point (used before every user action so interactions branch cleanly). State can be serialized via `exportToString`/`importFromString` (used by the `record`/`replay` localStorage helpers in `script.js`). The hook between layers is `state.updater`, defined at the bottom of `script.js`: it calls `raft.update` and decides whether the model changed enough to warrant a new checkpoint.
2. **`src/state.rs`** (`State`) — time-travel layer, pure Rust. Wraps the model in a checkpoint history so the timeline slider can scrub backward (`rewind`) and forward (`advance`/`seek`). `fork()` discards the future from the current point (used before every user action so interactions branch cleanly). `export_to_string`/`import_from_string` serialize checkpoints via serde — ported but not yet wired to the UI (`#[allow(dead_code)]`). The JS `state.updater` hook is inlined as `State::run_update`: it calls `Model::update` and decides whether the model changed enough (ignoring `time`) to warrant a new checkpoint.

3. **`script.js`** — everything DOM/SVG. The `render.*` functions (`servers`, `messages`, `logs`, `clock`) redraw the SVG from `state.current`. A `requestAnimationFrame` loop (the `step` function) advances model time by wall-time-elapsed divided by the speed factor, then re-renders. Keyboard shortcuts and context menus map to the `raft.*` actions. `playback` manages pause/resume.
3. **`src/lib.rs`** — everything DOM/SVG via `web-sys`. The `render_*` functions (`render_servers`, `render_messages`, `render_logs`, `render_clock`) imperatively redraw the SVG from `state.current`. A `requestAnimationFrame` loop (the `step` function) advances model time by wall-time-elapsed divided by the speed factor, then re-renders. Keyboard shortcuts and context menus map to the `raft` actions. The app lives in a `thread_local APP: RefCell<Option<App>>` cell, accessed via `with_app()`; `App` retains event-handler `Closure`s so they outlive attachment.

### Things that will bite you

- **Time is in microseconds.** All the constants in `raft.js` (`RPC_TIMEOUT`, `ELECTION_TIMEOUT`, latencies) and `model.time` are microsecond values. The UI divides by `1e6` to show seconds. `util.Inf` (`1e300`, not `Infinity`) is used for "never" because `JSON.stringify(Infinity)` is `null`, which would break checkpoint serialization.
- **Time is `f64` microseconds.** All the constants in `raft.rs` (`RPC_TIMEOUT`, `ELECTION_TIMEOUT`, latencies) and `Model::time` are microsecond values. The UI divides by `1e6` to show seconds. `util::INF` (`1e300`, not `f64::INFINITY`) is used for "never" because `serde_json` serializes `Infinity` to `null`, which would corrupt checkpoint serialization.

- **SVG `className` is read-only.** It is an `SVGAnimatedString`; calling `set_class_name` on an SVG element throws. Always set classes with `set_attr(&node, "class", ...)`.

- **Peer maps are `BTreeMap<u32, T>`**, keyed by 1-based peer id. `BTreeMap` (not `HashMap`) keeps iteration order deterministic, which keeps `PartialEq`-based checkpoint diffing stable.

- **Protocol rules operate by server index**, e.g. `fn rule(model: &mut Model, i: usize)`, not by holding a `&mut Server` — so a rule can read peers while mutating one server without fighting the borrow checker.

- **Rendering is checkpoint-diffed.** `render.update` compares `state.current` against `state.base()` (the last checkpoint) via `util.equals` to skip redundant server/message redraws (`serversSame`/`messagesSame`). If you add fields to the model that should trigger a redraw, make sure `util.equals` sees them.
- **`NUM_SERVERS` is fixed at 5** and baked into both protocol math (majority = `NUM_SERVERS/2 + 1`) and SVG layout (servers placed around a ring via `util::circle_coord`).

- **`NUM_SERVERS` is fixed at 5** and baked into both protocol math (majority = `Math.floor(NUM_SERVERS/2)+1`) and SVG layout (servers placed around a ring via `util.circleCoord`).
- **The speed slider is logarithmic.** `speed_transform` does `max(1, 10^v)`; the displayed value is "1/Nx" (slower than real time). The slider is reversed (`direction: rtl`).

- **The speed slider is logarithmic.** `speedSliderTransform` does `10^v`; the displayed value is "1/Nx" (slower than real time). The slider is reversed.
- **Every user action follows the same dance:** `state.fork()` → mutate via a `raft` function → `state.save()` → re-render. Preserve this ordering when adding new interactions, or the timeline history will desync.

- **Every user action follows the same dance:** `state.fork()` → mutate via a `raft.*` function → `state.save()` → `render.update()`. Preserve this ordering when adding new interactions, or the timeline history will desync.
- **`random()` is cfg-gated.** `js_sys::Math::random` on `wasm32`, a `thread_local` xorshift on the host so `cargo test` is deterministic without a browser.
247 changes: 247 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading