diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..623cfbb --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,38 @@ +name: Deploy LeanViz to GitHub Pages + +on: + push: + branches: + - devnet-1 + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: web + + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md index a148f6f..e1f5915 100644 --- a/README.md +++ b/README.md @@ -1 +1,25 @@ -# leanviz \ No newline at end of file +# leanviz + +LeanViz is a standalone, client-agnostic dashboard for Lean consensus networks. + +## Run +Serve the static files: +`python3 -m http.server 7070 --directory web` + +Open: +`http://localhost:7070/index.html` + +API check page: +`http://localhost:7070/health.html` + +## Configure +Edit `web/config/config.json` or use query params: +- `?beacon=http://localhost:5052` +- `?metrics=http://localhost:9090` +- `?mode=live` +- `?ns=eth` (use `/eth/v1/...` instead of `/lean/v0/...`) + +## Docs +- `docs/api-contract.md` +- `docs/runbook.md` +- `docs/changes.md` diff --git a/docs/api-contract.md b/docs/api-contract.md new file mode 100644 index 0000000..ae83b6b --- /dev/null +++ b/docs/api-contract.md @@ -0,0 +1,148 @@ +# LeanViz Monitoring API Contract (v0) + +This document defines the minimum API surface required by the LeanViz dashboard. +It is intentionally small and focused on monitoring data. + +## Status +- UI defaults to Lean-style paths (`/lean/v0/...`). +- Clients may also serve `/eth/v1/...` as a compatibility alias during migration. + +## Base URL +All endpoints are relative to a base URL (configured in `web/config/config.json`). + +## Endpoints (Required) +1. `GET /lean/v0/beacon/genesis` +2. `GET /lean/v0/beacon/headers/head` +3. `GET /lean/v0/beacon/states/head/finality_checkpoints` +4. `GET /lean/v0/beacon/states/head/validators` +5. `GET /lean/v0/node/peers` +6. `GET /lean/v0/node/syncing` +7. `GET /lean/v0/events` (SSE) + +Compatibility alias (optional): +- `/eth/v1/...` with the same semantics + +## Response Shape (Minimum Fields) +The UI only depends on the following fields. Extra fields are ignored. + +1. `/beacon/headers/head` +- `data.root` or `root` +- `data.header.message.slot` or `slot` +- `data.header.message.proposer_index` or `proposer_index` + +2. `/beacon/states/head/finality_checkpoints` +- `data.current_justified.slot` or `current_justified_slot` +- `data.current_justified.epoch` or `current_justified.epoch` +- `data.current_justified.root` or `current_justified.root` +- `data.finalized.slot` or `finalized_slot` +- `data.finalized.epoch` or `finalized.epoch` +- `data.finalized.root` or `finalized.root` + +3. `/beacon/states/head/validators` +- `data[]` array length determines validator count +- `data[i].index` or `data[i].validator.index` +- `data[i].status` or `data[i].validator.status` + +4. `/node/peers` +- `data[]` array length determines peer count + +5. `/node/syncing` +- `data.head_slot` (optional; used only for display) + +## Example Responses +1. `/beacon/headers/head` +```json +{ + "data": { + "root": "0xabc123...", + "header": { + "message": { + "slot": "107", + "proposer_index": "22" + } + } + } +} +``` + +2. `/beacon/states/head/finality_checkpoints` +```json +{ + "data": { + "current_justified": { + "epoch": "3", + "slot": "96", + "root": "0xdef456..." + }, + "finalized": { + "epoch": "3", + "slot": "98", + "root": "0x789abc..." + } + } +} +``` + +3. `/beacon/states/head/validators` +```json +{ + "data": [ + { "index": "0", "status": "active_ongoing" }, + { "index": "1", "status": "active_ongoing" } + ] +} +``` + +4. `/node/peers` +```json +{ + "data": [ + { "peer_id": "16Uiu2H..." } + ] +} +``` + +5. `/node/syncing` +```json +{ + "data": { + "head_slot": "107" + } +} +``` + +## SSE Events +Endpoint: `/events?topics=head,block,finalized_checkpoint,chain_reorg,attester_slashing,proposer_slashing` + +The UI uses only: +- `head` or `block` events + - `slot` + - `proposer_index` (optional) + - `block` or `root` +- `finalized_checkpoint` events + - `slot` or `finalized_slot` + - `epoch` (optional) + - `block` or `root` +- `chain_reorg` events + - payload is not parsed +- `attester_slashing` or `proposer_slashing` events + - `validator_index` or `proposer_index` or `index` + +## Example SSE Events +``` +event: head +data: {"slot":"107","proposer_index":"22","block":"0xabc123..."} + +event: finalized_checkpoint +data: {"slot":"98","epoch":"3","block":"0x789abc..."} +``` + +## CORS +The API must allow the dashboard origin. +Recommended: +- `Access-Control-Allow-Origin: *` (dev) +- Restrict origin in production. + +## Notes +- The UI will run in "demo" mode if API calls fail. +- Missing fields will degrade the display but should not crash the UI. diff --git a/docs/changes.md b/docs/changes.md new file mode 100644 index 0000000..73934cb --- /dev/null +++ b/docs/changes.md @@ -0,0 +1,12 @@ +# Change Notes + +This file tracks significant refactors and structural changes in the LeanViz codebase. + +## 2026-03-11 +- Extracted dashboard into a standalone repo layout under `web/`. +- Split monolithic HTML into `index.html`, `styles/main.css`, and JS modules. +- Added runtime config support via `web/config/config.json` and query params. +- Split JS into modules: `api/`, `render/`, `sim/`, `state`, `dom`, and `utils`. +- Preserved visual design and animation behavior. +- Default API namespace switched to `/lean/v0` with optional `/eth/v1` override. +- Added `web/health.html` API check page. diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..74f9c49 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,32 @@ +# LeanViz Runbook + +## Local Dev +Serve the static files: +`python3 -m http.server 7070 --directory web` + +Open: +`http://localhost:7070/index.html` + +API check page: +`http://localhost:7070/health.html` + +## Configure API URLs +Edit `web/config/config.json`: +``` +{ + "beaconUrl": "http://localhost:5052", + "metricsUrl": "http://localhost:9090", + "mode": "demo", + "apiNamespace": "lean" +} +``` + +Or use query params: +- `?beacon=http://localhost:5052` +- `?metrics=http://localhost:9090` +- `?mode=live` +- `?ns=eth` (use `/eth/v1/...` instead of `/lean/v0/...`) + +## Troubleshooting +- If UI shows no movement, check console errors. +- If API is unreachable, UI falls back to demo mode. diff --git a/web/config/config.json b/web/config/config.json new file mode 100644 index 0000000..40db964 --- /dev/null +++ b/web/config/config.json @@ -0,0 +1,6 @@ +{ + "beaconUrl": "http://localhost:5052", + "metricsUrl": "http://localhost:9090", + "mode": "demo", + "apiNamespace": "lean" +} diff --git a/web/health.html b/web/health.html new file mode 100644 index 0000000..85d96f7 --- /dev/null +++ b/web/health.html @@ -0,0 +1,266 @@ + + + + + + LeanViz API Check + + + +

LeanViz API Check

+
+
+
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+ +
+ + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..5188e2e --- /dev/null +++ b/web/index.html @@ -0,0 +1,143 @@ + + + + + + LeanViz - 3 Slot Finality Dashboard + + + + + + + +
+
+
+
+ 🔐 PQ-SECURE + ● LIVE SIM + +
+
+
Slot100
+
Epoch3
+
Finalized97
+
Participation0%
+
+
+
⚠ Cannot reach Lean node - running in demo mode
+
+
Modedemo
+
Transportsimulation
+
Last REST OKnever
+
Last SSE Eventnever
+
Last Errornone
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ Debug Metrics +
+
Goroutines
-
+
Memory MB
-
+
Live Events
0
+
Last Event
-
+
+
+
+ +
+ + +
+
+

Chain River

+ newest right / oldest left +
+
+ +
+
+ +
+
+

Validator Network

+ 24 validators / vote particles +
+
+ +
+
+
Active24
+
Voted0
+
Total24
+
Rate0%
+
+
+
+ + +
+ + + + + diff --git a/web/src/api/client.js b/web/src/api/client.js new file mode 100644 index 0000000..bbf23e1 --- /dev/null +++ b/web/src/api/client.js @@ -0,0 +1,71 @@ +import { asNumber, pickFirstNumber, pickFirstString } from "../utils.js"; + +export async function fetchJSON(url, { timeout = 5000 } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + const res = await fetch(url, { signal: controller.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } finally { + clearTimeout(timer); + } +} + +export async function fetchText(url, timeoutMs = 2500) { + const controller = new AbortController(); + const tid = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetch(url, { signal: controller.signal, cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.text(); + } finally { + clearTimeout(tid); + } +} + +export function parseHeadResponse(headResp) { + const slot = pickFirstNumber(headResp, ["slot"]); + const proposer = pickFirstNumber(headResp, ["proposer_index", "proposerIndex"]); + const root = pickFirstString(headResp, ["root", "block"]); + return { slot, proposer, root }; +} + +export function parseFinalityResponse(finalityData) { + const jSlot = asNumber(finalityData?.current_justified?.slot) ?? + asNumber(finalityData?.current_justified_slot) ?? + asNumber(finalityData?.justified_slot); + const jEpoch = asNumber(finalityData?.current_justified?.epoch) ?? + pickFirstNumber(finalityData?.current_justified, ["epoch"]); + const jRoot = finalityData?.current_justified?.root || pickFirstString(finalityData?.current_justified, ["root"]); + + const fSlot = asNumber(finalityData?.finalized?.slot) ?? + asNumber(finalityData?.finalized_slot); + const fEpoch = asNumber(finalityData?.finalized?.epoch) ?? + pickFirstNumber(finalityData?.finalized, ["epoch"]); + const fRoot = finalityData?.finalized?.root || pickFirstString(finalityData?.finalized, ["root"]); + + return { + justified: { slot: jSlot, epoch: jEpoch, root: jRoot }, + finalized: { slot: fSlot, epoch: fEpoch, root: fRoot } + }; +} + +export function parseHeadEvent(payload) { + const slot = pickFirstNumber(payload, ["slot"]); + const proposer = pickFirstNumber(payload, ["proposer_index", "proposerIndex"]); + const root = pickFirstString(payload, ["block", "block_root", "root"]); + return { slot, proposer, root }; +} + +export function parseFinalizedEvent(payload) { + const slot = pickFirstNumber(payload, ["slot", "finalized_slot"]); + const epoch = pickFirstNumber(payload, ["epoch"]); + const root = pickFirstString(payload, ["block", "root"]); + return { slot, epoch, root }; +} + +export function parseReorgEvent(payload) { + const idx = pickFirstNumber(payload, ["validator_index", "proposer_index", "index"]); + return { index: idx }; +} diff --git a/web/src/app.js b/web/src/app.js new file mode 100644 index 0000000..bbb02cb --- /dev/null +++ b/web/src/app.js @@ -0,0 +1,514 @@ +import { fetchJSON, fetchText, parseFinalityResponse, parseFinalizedEvent, parseHeadEvent, parseHeadResponse, parseReorgEvent } from "./api/client.js"; +import { loadConfig } from "./config.js"; +import { COLORS, PHASES, peers, state } from "./state.js"; +import { chainCanvas, chainCtx, nodes, validatorCanvas, validatorCtx } from "./dom.js"; +import { formatSince, randomInt } from "./utils.js"; +import { drawChainCanvas } from "./render/chain.js"; +import { drawValidatorCanvas } from "./render/validators.js"; +import { + addBlock, + applyFinalityCheckpoint, + applyHead, + applyLiveValidatorStatuses, + chooseProposer, + createSlotPlan, + currentSlotMs, + ensureValidators, + findLatestByStatus, + getPhase, + getValidatorCount, + onNewSlot, + updateVotes +} from "./sim/logic.js"; + +"use strict"; + + function setupPeerPills() { + nodes.peerPills.innerHTML = ""; + for (const p of peers) { + const el = document.createElement("div"); + el.className = `peer-pill ${p.self ? "self" : ""}`; + el.innerHTML = `${p.name} · ${p.lang}`; + nodes.peerPills.appendChild(el); + } + } + + function setWarning(msg) { + if (!msg) { + nodes.warningBanner.classList.remove("show"); + nodes.warningBanner.textContent = ""; + return; + } + nodes.warningBanner.textContent = `⚠ ${msg}`; + nodes.warningBanner.classList.add("show"); + } + + function toUserError(err, phase) { + const raw = err && err.message ? String(err.message) : String(err || "unknown error"); + const lower = raw.toLowerCase(); + if (lower.includes("failed to fetch") || lower.includes("networkerror") || lower.includes("load failed")) { + return `${phase}: network/CORS blocked (${state.live.beaconUrl})`; + } + if (lower.includes("abort")) { + return `${phase}: request timeout`; + } + return `${phase}: ${raw}`; + } + + function setLiveError(errText) { + state.live.lastError = errText || ""; + updateConnectionStrip(); + } + + function updateConnectionStrip() { + nodes.connMode.textContent = state.mode; + if (state.mode === "demo") { + nodes.connTransport.textContent = "simulation"; + } else { + nodes.connTransport.textContent = state.live.connected ? "sse + rest" : "connecting"; + } + nodes.connLastRest.textContent = formatSince(state.live.lastRestSuccessAt); + const evt = state.live.lastEventType ? `${state.live.lastEventType} · ${formatSince(state.live.lastEventAt)}` : "never"; + nodes.connLastEvent.textContent = evt; + nodes.connError.textContent = state.live.lastError || "none"; + } + + function setModeBadge() { + if (state.mode === "live") { + nodes.liveBadge.textContent = state.live.connected ? "● LIVE API" : "● LIVE (CONNECTING)"; + nodes.liveBadge.classList.toggle("warn", !state.live.connected); + } else { + nodes.liveBadge.textContent = "● LIVE SIM"; + nodes.liveBadge.classList.remove("warn"); + } + updateConnectionStrip(); + } + + function setMode(mode, reason = "") { + if (mode !== "live") mode = "demo"; + state.mode = mode; + localStorage.setItem("leanviz_mode", mode); + nodes.modeSelect.value = mode; + setModeBadge(); + if (mode === "demo") { + disconnectLive(); + if (reason) setWarning(reason); + } else { + setWarning(""); + setLiveError(""); + connectLive(); + } + updateSlotCycleLabel(); + } + + + function updateSlotCycleLabel() { + const sec = (currentSlotMs() / 1000).toFixed(1); + nodes.slotCycleLabel.textContent = `3 slot finality / ${sec}s cycle`; + } + + function setupPhases() { + nodes.phaseList.innerHTML = ""; + for (const phase of PHASES) { + const item = document.createElement("div"); + item.className = "phase-item pending"; + item.dataset.phase = phase.id; + item.innerHTML = ` +
+ ${phase.icon} + ${phase.label} + ··· +
+
${phase.desc}
+
+ `; + nodes.phaseList.appendChild(item); + } + } + + function resizeCanvas(canvas, ctx) { + const ratio = Math.min(window.devicePixelRatio || 1, 2); + const w = canvas.clientWidth; + const h = canvas.clientHeight; + canvas.width = Math.floor(w * ratio); + canvas.height = Math.floor(h * ratio); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + } + + + + + + + + + + + + + + + + + async function pollLiveREST() { + const base = state.live.beaconUrl.replace(/\/+$/, ""); + const ns = state.live.apiNamespace === "eth" ? "eth/v1" : "lean/v0"; + try { + const [headResp, finalityResp, peersResp, validatorsResp] = await Promise.all([ + fetchJSON(`${base}/${ns}/beacon/headers/head`), + fetchJSON(`${base}/${ns}/beacon/states/head/finality_checkpoints`), + fetchJSON(`${base}/${ns}/node/peers`), + fetchJSON(`${base}/${ns}/beacon/states/head/validators`) + ]); + + const headData = headResp?.data || headResp || {}; + const headParsed = parseHeadResponse(headData); + applyHead(headParsed.slot, headParsed.proposer, headParsed.root); + + const finalityData = finalityResp?.data || finalityResp || {}; + const finalityParsed = parseFinalityResponse(finalityData); + if (finalityParsed.justified.slot != null) { + applyFinalityCheckpoint("justified", finalityParsed.justified.root, finalityParsed.justified.slot); + } else if (finalityParsed.justified.epoch != null) { + applyFinalityCheckpoint("justified", finalityParsed.justified.root, finalityParsed.justified.epoch * 32); + } + if (finalityParsed.finalized.slot != null) { + applyFinalityCheckpoint("finalized", finalityParsed.finalized.root, finalityParsed.finalized.slot); + } else if (finalityParsed.finalized.epoch != null) { + applyFinalityCheckpoint("finalized", finalityParsed.finalized.root, finalityParsed.finalized.epoch * 32); + } + + const peerCount = Array.isArray(peersResp?.data) ? peersResp.data.length : peers.filter((p) => p.status !== "offline").length; + state.live.peerCount = peerCount; + nodes.mPeers.textContent = String(peerCount); + + const validatorRows = Array.isArray(validatorsResp?.data) ? validatorsResp.data : null; + const validatorCount = validatorRows ? validatorRows.length : getValidatorCount(); + ensureValidators(validatorCount); + if (validatorRows) applyLiveValidatorStatuses(validatorRows); + if (state.proposer >= getValidatorCount()) state.proposer = getValidatorCount() - 1; + state.live.lastRestSuccessAt = Date.now(); + setLiveError(""); + } catch (err) { + const userErr = toUserError(err, "REST"); + setLiveError(userErr); + setMode("demo", `${userErr} - running in demo mode`); + } + } + + async function pollMetrics() { + if (!state.live.metricsUrl) return; + try { + const raw = await fetchText(`${state.live.metricsUrl.replace(/\/+$/, "")}/metrics`, 2000); + const gor = raw.match(/^go_goroutines\\s+([0-9.]+)/m); + const mem = raw.match(/^process_resident_memory_bytes\\s+([0-9.]+)/m); + nodes.dbgGoroutines.textContent = gor ? Math.round(Number(gor[1])).toString() : "-"; + nodes.dbgMemory.textContent = mem ? (Number(mem[1]) / (1024 * 1024)).toFixed(1) : "-"; + state.live.lastMetricsSuccessAt = Date.now(); + } catch (_) { + nodes.dbgGoroutines.textContent = "-"; + nodes.dbgMemory.textContent = "-"; + } + } + + function handleSSE(type, payload) { + state.live.lastEventAt = Date.now(); + state.live.lastEventType = type; + state.live.eventsSeen += 1; + nodes.dbgEvents.textContent = String(state.live.eventsSeen); + nodes.dbgLastEvent.textContent = type; + if (state.mode === "live") setLiveError(""); + + if (type === "head" || type === "block") { + const parsed = parseHeadEvent(payload); + applyHead(parsed.slot, parsed.proposer, parsed.root); + return; + } + + if (type === "finalized_checkpoint") { + const parsed = parseFinalizedEvent(payload); + if (parsed.slot != null) applyFinalityCheckpoint("finalized", parsed.root, parsed.slot); + else if (parsed.epoch != null) applyFinalityCheckpoint("finalized", parsed.root, parsed.epoch * 32); + return; + } + + if (type === "chain_reorg") { + setWarning("Chain reorg event observed from Lean node"); + return; + } + + if (type === "attester_slashing" || type === "proposer_slashing") { + const parsed = parseReorgEvent(payload); + const idx = parsed.index; + if (idx != null && idx < state.validators.length) { + state.validators[idx].status = "slashed"; + setTimeout(() => { + if (state.validators[idx].status === "slashed") state.validators[idx].status = "idle"; + }, 2500); + } + } + } + + function connectLive() { + disconnectLive(); + state.live.connected = false; + setModeBadge(); + const base = state.live.beaconUrl.replace(/\/+$/, ""); + const ns = state.live.apiNamespace === "eth" ? "eth/v1" : "lean/v0"; + const sseUrl = `${base}/${ns}/events?topics=head,block,finalized_checkpoint,chain_reorg,attester_slashing,proposer_slashing`; + + try { + const es = new EventSource(sseUrl); + state.live.eventSource = es; + + es.onopen = () => { + state.live.connected = true; + state.live.lastEventAt = Date.now(); + setWarning(""); + setLiveError(""); + setModeBadge(); + }; + + es.onerror = () => { + const errText = `SSE: network/CORS blocked (${state.live.beaconUrl})`; + setLiveError(errText); + setMode("demo", `${errText} - running in demo mode`); + }; + + const bindEvent = (name) => { + es.addEventListener(name, (ev) => { + let payload = {}; + try { + payload = ev.data ? JSON.parse(ev.data) : {}; + } catch (_) {} + handleSSE(name, payload); + }); + }; + ["head", "block", "finalized_checkpoint", "chain_reorg", "attester_slashing", "proposer_slashing"].forEach(bindEvent); + } catch (err) { + const userErr = toUserError(err, "SSE"); + setLiveError(userErr); + setMode("demo", `${userErr} - running in demo mode`); + return; + } + + state.live.restTimer = setInterval(() => { + pollLiveREST(); + pollMetrics(); + }, currentSlotMs()); + pollLiveREST(); + pollMetrics(); + + state.live.watchdogTimer = setInterval(() => { + if (state.mode !== "live") return; + const idleFor = Date.now() - state.live.lastEventAt; + if (idleFor > 20000) { + setLiveError("SSE: timeout waiting for events"); + setMode("demo", "Live SSE timeout - running in demo mode"); + } + }, 5000); + } + + function disconnectLive() { + if (state.live.eventSource) { + state.live.eventSource.close(); + state.live.eventSource = null; + } + if (state.live.restTimer) { + clearInterval(state.live.restTimer); + state.live.restTimer = null; + } + if (state.live.watchdogTimer) { + clearInterval(state.live.watchdogTimer); + state.live.watchdogTimer = null; + } + state.live.connected = false; + setModeBadge(); + } + + function bindControls() { + nodes.beaconInput.value = state.live.beaconUrl; + nodes.metricsInput.value = state.live.metricsUrl; + nodes.nsSelect.value = state.live.apiNamespace === "eth" ? "eth" : "lean"; + nodes.modeSelect.value = state.mode; + + nodes.settingsBtn.addEventListener("click", () => { + nodes.settingsPanel.classList.toggle("open"); + }); + + nodes.applySettings.addEventListener("click", () => { + state.live.beaconUrl = nodes.beaconInput.value.trim() || "http://localhost:5052"; + state.live.metricsUrl = nodes.metricsInput.value.trim() || "http://localhost:9090"; + state.live.apiNamespace = nodes.nsSelect.value === "eth" ? "eth" : "lean"; + localStorage.setItem("leanviz_beacon_url", state.live.beaconUrl); + localStorage.setItem("leanviz_metrics_url", state.live.metricsUrl); + localStorage.setItem("leanviz_api_ns", state.live.apiNamespace); + + const mode = nodes.modeSelect.value === "live" ? "live" : "demo"; + setMode(mode); + }); + } + + + + function updateUI(now) { + updateConnectionStrip(); + const phase = getPhase(now); + state.currentPhase = phase.id; + state.phaseProgress = phase.progress; + + const untilNext = Math.max(0, currentSlotMs() - phase.elapsed) / 1000; + nodes.countdown.textContent = `NEXT SLOT IN ${untilNext.toFixed(1)}s`; + nodes.leftSlot.textContent = `SLOT ${state.currentSlot}`; + nodes.proposerText.textContent = `Proposer: Validator #${state.proposer}`; + nodes.finalizeHint.textContent = `Slot ${Math.max(0, state.currentSlot - 3)} finalizes this round`; + + const phaseItems = nodes.phaseList.querySelectorAll(".phase-item"); + PHASES.forEach((p, idx) => { + const item = phaseItems[idx]; + const bar = item.querySelector(".phase-progress > span"); + const phaseState = item.querySelector(".phase-state"); + const currentIdx = PHASES.findIndex((x) => x.id === phase.id); + + item.classList.remove("active", "done", "pending"); + if (idx < currentIdx) { + item.classList.add("done"); + phaseState.textContent = "✓"; + bar.style.width = "100%"; + } else if (idx === currentIdx) { + item.classList.add("active"); + phaseState.textContent = "●"; + bar.style.width = `${Math.max(3, phase.progress * 100)}%`; + } else { + item.classList.add("pending"); + phaseState.textContent = "···"; + bar.style.width = "0%"; + } + }); + + const justified = findLatestByStatus("JUSTIFIED"); + const finalized = findLatestByStatus("FINALIZED"); + + const finalizedSlot = state.finalizedCheckpoint?.slot ?? (finalized ? finalized.slot : Math.max(0, state.currentSlot - 2)); + const justifiedSlot = state.justifiedCheckpoint?.slot ?? (justified ? justified.slot : Math.max(0, state.currentSlot - 1)); + const gap = Math.max(0, state.currentSlot - finalizedSlot); + + nodes.topSlot.textContent = String(state.currentSlot); + nodes.topEpoch.textContent = String(state.epoch); + nodes.topFinalized.textContent = String(finalizedSlot); + nodes.topPart.textContent = `${state.participationRate}%`; + + nodes.justSlot.textContent = String(justifiedSlot); + nodes.justHash.textContent = state.justifiedCheckpoint?.root ?? (justified ? justified.hash : "0x-"); + nodes.finSlot.textContent = String(finalizedSlot); + nodes.finHash.textContent = state.finalizedCheckpoint?.root ?? (finalized ? finalized.hash : "0x-"); + nodes.gapTitle.textContent = `HEAD → FINALIZED GAP: ${gap} SLOTS`; + + const maxGap = 14; + nodes.gapFill.style.width = `${Math.min(100, (gap / maxGap) * 100)}%`; + if (gap <= 5) { + nodes.gapFill.style.backgroundColor = COLORS.green; + } else if (gap <= 10) { + nodes.gapFill.style.backgroundColor = COLORS.amber; + } else { + nodes.gapFill.style.backgroundColor = COLORS.red; + } + + const chips = state.finalizedHistory.slice(0, 10); + nodes.chipWrap.innerHTML = ""; + for (const slot of chips) { + const chip = document.createElement("span"); + chip.className = "chip"; + chip.textContent = slot; + nodes.chipWrap.appendChild(chip); + } + + nodes.mSlot.textContent = String(state.currentSlot); + nodes.mPart.textContent = `${state.participationRate}%`; + nodes.mMissed.textContent = String(state.missedSlots); + nodes.mPeers.textContent = state.mode === "live" + ? String(state.live.peerCount || 0) + : String(peers.filter((p) => p.status !== "offline").length); + + const validatorCount = getValidatorCount(); + const activeValidatorCount = state.validators.slice(0, validatorCount).filter((v) => v.active !== false).length; + const votedActiveCount = Array.from(state.currentVoters.values()) + .filter((id) => state.validators[id] && state.validators[id].active !== false).length; + const rateBase = Math.max(1, activeValidatorCount); + nodes.statTotal.textContent = String(validatorCount); + nodes.statActive.textContent = String(activeValidatorCount); + nodes.validatorSubtitle.textContent = `${activeValidatorCount} active / ${validatorCount} total validators`; + nodes.statVoted.textContent = String(votedActiveCount); + nodes.statRate.textContent = `${Math.round((votedActiveCount / rateBase) * 100)}%`; + } + + + + + + function bootstrap() { + setupPeerPills(); + setupPhases(); + bindControls(); + updateSlotCycleLabel(); + resizeCanvas(chainCanvas, chainCtx); + resizeCanvas(validatorCanvas, validatorCtx); + requestAnimationFrame(() => { + resizeCanvas(chainCanvas, chainCtx); + resizeCanvas(validatorCanvas, validatorCtx); + }); + + createSlotPlan(); + for (let s = state.currentSlot - 14; s <= state.currentSlot; s++) { + state.proposer = chooseProposer(); + state.participationRate = randomInt(82, 99); + addBlock(s, Math.random() < 0.06); + } + state.finalizedHistory = state.blocks + .filter((b) => !b.missed && b.slot <= state.currentSlot - 2) + .slice(-12) + .map((b) => b.slot) + .reverse(); + const j = state.blocks.find((b) => b.slot === state.currentSlot - 1 && !b.missed); + const f = state.blocks.find((b) => b.slot === state.currentSlot - 2 && !b.missed); + if (j) state.justifiedCheckpoint = { slot: j.slot, root: j.hash }; + if (f) state.finalizedCheckpoint = { slot: f.slot, root: f.hash }; + setModeBadge(); + if (state.mode === "live") connectLive(); + + state.lastFrame = performance.now(); + requestAnimationFrame(tick); + } + + function tick(now) { + try { + while (state.mode === "demo" && now - state.slotStartedAt >= currentSlotMs()) { + onNewSlot(); + } + + updateUI(now); + updateVotes(now); + drawChainCanvas(now); + drawValidatorCanvas(now); + + state.lastFrame = now; + requestAnimationFrame(tick); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + } + } + + window.addEventListener("resize", () => { + resizeCanvas(chainCanvas, chainCtx); + resizeCanvas(validatorCanvas, validatorCtx); + }); + window.addEventListener("load", () => { + resizeCanvas(chainCanvas, chainCtx); + resizeCanvas(validatorCanvas, validatorCtx); + }); + + export async function init() { + await loadConfig(state); + bootstrap(); + } diff --git a/web/src/config.js b/web/src/config.js new file mode 100644 index 0000000..8527000 --- /dev/null +++ b/web/src/config.js @@ -0,0 +1,34 @@ +export async function loadConfig(state) { + const params = new URLSearchParams(window.location.search); + let cfg = {}; + try { + const res = await fetch("config/config.json", { cache: "no-store" }); + if (res.ok) { + cfg = await res.json(); + } + } catch (err) { + // Optional config file; ignore missing/blocked fetch. + } + + const beacon = params.get("beacon") || cfg.beaconUrl; + const metrics = params.get("metrics") || cfg.metricsUrl; + const mode = params.get("mode") || cfg.mode; + const ns = params.get("ns") || cfg.apiNamespace; + + if (beacon) { + state.live.beaconUrl = beacon; + localStorage.setItem("leanviz_beacon_url", beacon); + } + if (metrics) { + state.live.metricsUrl = metrics; + localStorage.setItem("leanviz_metrics_url", metrics); + } + if (mode) { + state.mode = mode; + localStorage.setItem("leanviz_mode", mode); + } + if (ns) { + state.live.apiNamespace = ns; + localStorage.setItem("leanviz_api_ns", ns); + } +} diff --git a/web/src/dom.js b/web/src/dom.js new file mode 100644 index 0000000..5319ffd --- /dev/null +++ b/web/src/dom.js @@ -0,0 +1,52 @@ +export const nodes = { + peerPills: document.getElementById("peerPills"), + liveBadge: document.getElementById("liveBadge"), + warningBanner: document.getElementById("warningBanner"), + connMode: document.getElementById("connMode"), + connTransport: document.getElementById("connTransport"), + connLastRest: document.getElementById("connLastRest"), + connLastEvent: document.getElementById("connLastEvent"), + connError: document.getElementById("connError"), + settingsBtn: document.getElementById("settingsBtn"), + settingsPanel: document.getElementById("settingsPanel"), + beaconInput: document.getElementById("beaconInput"), + metricsInput: document.getElementById("metricsInput"), + nsSelect: document.getElementById("nsSelect"), + modeSelect: document.getElementById("modeSelect"), + applySettings: document.getElementById("applySettings"), + topSlot: document.getElementById("topSlot"), + topEpoch: document.getElementById("topEpoch"), + topFinalized: document.getElementById("topFinalized"), + topPart: document.getElementById("topPart"), + validatorSubtitle: document.getElementById("validatorSubtitle"), + leftSlot: document.getElementById("leftSlot"), + proposerText: document.getElementById("proposerText"), + phaseList: document.getElementById("phaseList"), + slotCycleLabel: document.getElementById("slotCycleLabel"), + countdown: document.getElementById("countdown"), + finalizeHint: document.getElementById("finalizeHint"), + statActive: document.getElementById("statActive"), + statVoted: document.getElementById("statVoted"), + statTotal: document.getElementById("statTotal"), + statRate: document.getElementById("statRate"), + justSlot: document.getElementById("justSlot"), + justHash: document.getElementById("justHash"), + finSlot: document.getElementById("finSlot"), + finHash: document.getElementById("finHash"), + gapTitle: document.getElementById("gapTitle"), + gapFill: document.getElementById("gapFill"), + chipWrap: document.getElementById("chipWrap"), + mSlot: document.getElementById("mSlot"), + mPart: document.getElementById("mPart"), + mMissed: document.getElementById("mMissed"), + mPeers: document.getElementById("mPeers"), + dbgGoroutines: document.getElementById("dbgGoroutines"), + dbgMemory: document.getElementById("dbgMemory"), + dbgEvents: document.getElementById("dbgEvents"), + dbgLastEvent: document.getElementById("dbgLastEvent") +}; + +export const chainCanvas = document.getElementById("chainCanvas"); +export const chainCtx = chainCanvas.getContext("2d"); +export const validatorCanvas = document.getElementById("validatorCanvas"); +export const validatorCtx = validatorCanvas.getContext("2d"); diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..b629053 --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,3 @@ +import { init } from "./app.js"; + +init(); diff --git a/web/src/render/chain.js b/web/src/render/chain.js new file mode 100644 index 0000000..1f60ef0 --- /dev/null +++ b/web/src/render/chain.js @@ -0,0 +1,141 @@ +import { COLORS, state } from "../state.js"; +import { chainCanvas, chainCtx } from "../dom.js"; +import { currentSlotMs, getBlockStatus } from "../sim/logic.js"; +import { roundRect } from "./primitives.js"; + +export function drawChainCanvas(now) { + const ctx = chainCtx; + const w = chainCanvas.clientWidth; + const h = chainCanvas.clientHeight; + ctx.clearRect(0, 0, w, h); + + for (let x = 0; x < w; x += 40) { + ctx.strokeStyle = "rgba(255,255,255,0.04)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x + 0.5, 0); + ctx.lineTo(x + 0.5, h); + ctx.stroke(); + } + for (let y = 0; y < h; y += 40) { + ctx.strokeStyle = "rgba(255,255,255,0.03)"; + ctx.beginPath(); + ctx.moveTo(0, y + 0.5); + ctx.lineTo(w, y + 0.5); + ctx.stroke(); + } + + const blockW = 128; + const blockH = 116; + const spacing = 30; + const slotMs = currentSlotMs(); + const speed = (blockW + spacing) / slotMs; + const baseY = h * 0.48; + const visible = []; + + const phaseElapsed = (now - state.slotStartedAt + slotMs) % slotMs; + + for (const block of state.blocks) { + const ageMs = (state.currentSlot - block.slot) * slotMs + phaseElapsed; + const x = w - 130 - ageMs * speed; + if (x < -blockW - 80 || x > w + 120) continue; + visible.push({ block, x, y: baseY }); + } + + visible.sort((a, b) => a.block.slot - b.block.slot); + + for (let i = 1; i < visible.length; i++) { + const prev = visible[i - 1]; + const cur = visible[i]; + const gap = cur.block.slot - prev.block.slot; + ctx.beginPath(); + ctx.moveTo(prev.x + blockW, prev.y + blockH / 2); + ctx.lineTo(cur.x, cur.y + blockH / 2); + ctx.strokeStyle = gap > 1 ? "rgba(100,116,139,0.7)" : "rgba(59,130,246,0.45)"; + ctx.setLineDash(gap > 1 ? [6, 4] : []); + ctx.lineWidth = 2; + ctx.stroke(); + } + ctx.setLineDash([]); + + for (const item of visible) { + const { block, x, y } = item; + const status = getBlockStatus(block); + const fade = Math.max(0.1, Math.min(1, (x + 80) / (w * 0.52))); + + let color = COLORS.blue; + if (status === "JUSTIFIED") color = COLORS.amber; + if (status === "FINALIZED") color = COLORS.green; + if (status === "MISSED") color = COLORS.grey; + + ctx.globalAlpha = fade; + roundRect(ctx, x, y, blockW, blockH, 12); + ctx.fillStyle = "rgba(13,13,20,0.95)"; + ctx.fill(); + ctx.lineWidth = 1.2; + ctx.strokeStyle = status === "MISSED" ? "rgba(42,42,58,1)" : color; + if (status === "MISSED") ctx.setLineDash([5, 3]); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.shadowBlur = status === "MISSED" ? 0 : 18; + ctx.shadowColor = status === "FINALIZED" ? "rgba(34,197,94,0.65)" : `${color}99`; + if (block.flashUntil > now) { + ctx.shadowBlur = 30; + ctx.shadowColor = "rgba(34,197,94,0.9)"; + } + roundRect(ctx, x, y, blockW, blockH, 12); + ctx.strokeStyle = status === "MISSED" ? "rgba(42,42,58,0.9)" : `${color}cc`; + ctx.stroke(); + ctx.shadowBlur = 0; + + ctx.font = "700 13px 'Space Mono'"; + ctx.fillStyle = status === "MISSED" ? "#9aa8bb" : COLORS.cyan; + ctx.fillText(`SLOT ${block.slot}`, x + 8, y + 16); + + ctx.font = "10px 'Space Mono'"; + ctx.fillStyle = COLORS.muted; + ctx.fillText(block.hash, x + 8, y + 30); + + ctx.fillStyle = "#a6b5cb"; + ctx.fillText(`Proposer: #${block.proposer}`, x + 8, y + 43); + + const sourceSlot = block.checkpoint?.sourceSlot ?? Math.max(0, block.slot - 2); + const targetSlot = block.checkpoint?.targetSlot ?? Math.max(0, block.slot - 1); + const headSlot = block.checkpoint?.headSlot ?? block.slot; + ctx.fillStyle = "#8197b8"; + ctx.fillText(`CP S:${sourceSlot} T:${targetSlot} H:${headSlot}`, x + 8, y + 56); + + const barY = y + 70; + roundRect(ctx, x + 8, barY, blockW - 16, 7, 4); + ctx.fillStyle = "rgba(255,255,255,0.07)"; + ctx.fill(); + + const fill = status === "MISSED" ? 0 : (Math.max(0, Math.min(100, block.participation)) / 100) * (blockW - 16); + roundRect(ctx, x + 8, barY, fill, 7, 4); + ctx.fillStyle = status === "MISSED" ? "#434a5d" : COLORS.purple; + ctx.fill(); + ctx.font = "700 8px 'Space Mono'"; + ctx.fillStyle = "#9bb3d7"; + ctx.fillText(`ATT ${Math.round(block.participation)}%`, x + blockW - 58, barY - 2); + + const badgeY = y + 92; + roundRect(ctx, x + 8, badgeY, blockW - 16, 16, 6); + ctx.fillStyle = status === "MISSED" ? "rgba(42,42,58,0.9)" : `${color}26`; + ctx.fill(); + ctx.strokeStyle = status === "MISSED" ? "#3c4354" : `${color}cc`; + ctx.stroke(); + + ctx.font = "700 9px 'Space Mono'"; + ctx.fillStyle = status === "MISSED" ? "#9aa8bb" : color; + const label = status === "FINALIZED" ? "FINALIZED 🔒" : status; + ctx.fillText(label, x + 12, y + 103); + + if (status === "MISSED") { + ctx.font = "700 24px 'Space Mono'"; + ctx.fillStyle = "rgba(154,168,187,0.65)"; + ctx.fillText("×", x + blockW - 28, y + 24); + } + } + ctx.globalAlpha = 1; +} diff --git a/web/src/render/primitives.js b/web/src/render/primitives.js new file mode 100644 index 0000000..a1c6ea8 --- /dev/null +++ b/web/src/render/primitives.js @@ -0,0 +1,52 @@ +export function roundRect(ctx, x, y, w, h, r) { + const rr = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + rr, y); + ctx.arcTo(x + w, y, x + w, y + h, rr); + ctx.arcTo(x + w, y + h, x, y + h, rr); + ctx.arcTo(x, y + h, x, y, rr); + ctx.arcTo(x, y, x + w, y, rr); + ctx.closePath(); +} + +export function drawValidatorAvatar(ctx, x, y, size, color, label, emphasis, labelColor = "#9fb5d7") { + roundRect(ctx, x - size / 2, y - size / 2, size, size, 6); + ctx.fillStyle = "rgba(12,15,24,0.95)"; + ctx.fill(); + ctx.lineWidth = 1.2; + ctx.strokeStyle = color; + ctx.stroke(); + + // Head + ctx.beginPath(); + ctx.arc(x, y - 3, 2.2, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + // Body + ctx.beginPath(); + ctx.moveTo(x, y - 0.5); + ctx.lineTo(x, y + 4.5); + ctx.strokeStyle = color; + ctx.lineWidth = 1.3; + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x - 2.8, y + 2); + ctx.lineTo(x + 2.8, y + 2); + ctx.stroke(); + + if (emphasis) { + ctx.shadowBlur = 12; + ctx.shadowColor = `${color}aa`; + roundRect(ctx, x - size / 2, y - size / 2, size, size, 6); + ctx.strokeStyle = color; + ctx.stroke(); + ctx.shadowBlur = 0; + } + + if (label) { + ctx.font = "700 9px 'Space Mono'"; + ctx.fillStyle = labelColor; + ctx.textAlign = "center"; + ctx.fillText(label, x, y - size / 2 - 4); + } +} diff --git a/web/src/render/validators.js b/web/src/render/validators.js new file mode 100644 index 0000000..b087bec --- /dev/null +++ b/web/src/render/validators.js @@ -0,0 +1,128 @@ +import { COLORS, state } from "../state.js"; +import { validatorCanvas, validatorCtx } from "../dom.js"; +import { getValidatorCount } from "../sim/logic.js"; +import { drawValidatorAvatar } from "./primitives.js"; + +export function drawValidatorCanvas(now) { + const ctx = validatorCtx; + const w = validatorCanvas.clientWidth; + const h = validatorCanvas.clientHeight; + ctx.clearRect(0, 0, w, h); + + const cx = w / 2; + const cy = h / 2 - 4; + const ringR = Math.min(w, h) * 0.34; + + const centerPulse = ((now % 1300) / 1300); + ctx.beginPath(); + ctx.arc(cx, cy, 18, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,255,204,0.2)"; + ctx.fill(); + ctx.strokeStyle = COLORS.cyan; + ctx.lineWidth = 1.4; + ctx.shadowBlur = 18; + ctx.shadowColor = "rgba(0,255,204,0.7)"; + ctx.stroke(); + ctx.shadowBlur = 0; + + ctx.beginPath(); + ctx.arc(cx, cy, 18 + centerPulse * 36, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(0,255,204,${1 - centerPulse})`; + ctx.lineWidth = 1.2; + ctx.stroke(); + + ctx.font = "700 11px 'Space Mono'"; + ctx.fillStyle = "#c9fff4"; + ctx.textAlign = "center"; + ctx.fillText("BLOCK", cx, cy - 2); + ctx.font = "700 13px 'Space Mono'"; + ctx.fillText(String(state.currentSlot), cx, cy + 12); + + const count = getValidatorCount(); + const nodePos = []; + for (let i = 0; i < count; i++) { + const a = (-Math.PI / 2) + (i / count) * Math.PI * 2; + nodePos.push({ id: i, x: cx + Math.cos(a) * ringR, y: cy + Math.sin(a) * ringR }); + } + + ctx.strokeStyle = "rgba(100,116,139,0.2)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(cx, cy, ringR, 0, Math.PI * 2); + ctx.stroke(); + + state.voteRings = state.voteRings.filter((r) => now - r.startAt < r.duration); + for (const ring of state.voteRings) { + const p = nodePos[ring.validatorId]; + if (!p) continue; + const t = (now - ring.startAt) / ring.duration; + const rr = 6 + t * 18; + ctx.beginPath(); + ctx.arc(p.x, p.y, rr, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(123,97,255,${1 - t})`; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + state.particles = state.particles.filter((pt) => now - pt.startAt < pt.duration); + for (const pt of state.particles) { + const p = nodePos[pt.validatorId]; + if (!p) continue; + const t = Math.min(1, (now - pt.startAt) / pt.duration); + const x = p.x + (cx - p.x) * (1 - (1 - t) * (1 - t)); + const y = p.y + (cy - p.y) * (1 - (1 - t) * (1 - t)); + + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(x, y); + ctx.strokeStyle = `rgba(123,97,255,${0.5 - t * 0.4})`; + ctx.lineWidth = 1.6; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(x, y, 3.2, 0, Math.PI * 2); + ctx.fillStyle = COLORS.purple; + ctx.shadowBlur = 10; + ctx.shadowColor = "rgba(123,97,255,0.9)"; + ctx.fill(); + ctx.shadowBlur = 0; + + if (pt.kind === "attestation" && t < 0.9) { + ctx.font = "700 8px 'Space Mono'"; + ctx.fillStyle = `rgba(174,196,255,${1 - t})`; + ctx.textAlign = "left"; + ctx.fillText("attestation", x + 5, y - 4); + } + } + + for (const v of state.validators.slice(0, count)) { + const p = nodePos[v.id]; + let color = "#54607a"; + let size = 10; + let label = `#${v.id}`; + let labelColor = "#7f8eaa"; + let emphasis = false; + if (v.active === false) { + color = "#2f3748"; + size = 10; + label = `#${v.id} off`; + labelColor = "#5f6d86"; + } else if (v.status === "proposer") { + color = COLORS.cyan; + size = 14; + labelColor = "#b8fff2"; + emphasis = true; + } else if (v.status === "voted") { + color = COLORS.green; + size = 12; + labelColor = "#9cf1b8"; + } else if (v.status === "slashed") { + color = COLORS.red; + size = 12; + labelColor = "#ffb1b1"; + emphasis = true; + } + drawValidatorAvatar(ctx, p.x, p.y, size, color, label, emphasis, labelColor); + } + ctx.textAlign = "left"; +} diff --git a/web/src/sim/logic.js b/web/src/sim/logic.js new file mode 100644 index 0000000..9904361 --- /dev/null +++ b/web/src/sim/logic.js @@ -0,0 +1,261 @@ +import { DEFAULT_VALIDATOR_COUNT, DEMO_SLOT_MS, LIVE_SLOT_MS, PHASES, state } from "../state.js"; +import { asNumber, randomHash, randomInt, truncateRoot } from "../utils.js"; + +export function currentSlotMs() { + return state.mode === "live" ? LIVE_SLOT_MS : DEMO_SLOT_MS; +} + +export function getValidatorCount() { + return Math.max(1, state.validatorCount || state.validators.length || DEFAULT_VALIDATOR_COUNT); +} + +export function ensureValidators(count) { + const c = Math.max(1, Number(count) || DEFAULT_VALIDATOR_COUNT); + state.validatorCount = c; + if (state.validators.length === c) return; + state.validators = Array.from({ length: c }, (_, i) => { + const prev = state.validators[i]; + return { + id: i, + status: prev?.status || "idle", + active: prev?.active !== false + }; + }); + state.currentVoters = new Set(); + state.targetVoters = []; + state.voteCursor = 0; + if (state.proposer >= c) state.proposer = c - 1; +} + +export function isValidatorActiveStatus(status) { + const s = String(status || "").toLowerCase(); + if (!s) return true; + if (s.includes("pending") || s.includes("exited") || s.includes("withdraw")) return false; + return s.includes("active"); +} + +export function applyLiveValidatorStatuses(rows) { + if (!Array.isArray(rows)) return; + ensureValidators(rows.length || getValidatorCount()); + + const activeByIndex = new Map(); + for (const row of rows) { + const idx = asNumber(row?.index ?? row?.validator?.index); + const status = row?.status ?? row?.validator?.status; + if (idx == null || idx < 0 || idx >= state.validators.length) continue; + activeByIndex.set(idx, isValidatorActiveStatus(status)); + } + + for (const v of state.validators) { + if (activeByIndex.has(v.id)) v.active = activeByIndex.get(v.id); + } +} + +export function makeDeterministicVoters(slot, proposer, voteCount, count, seed = "") { + const pool = Array.from({ length: count }, (_, i) => i).filter((v) => v !== proposer); + const baseSeed = String(seed || "") + ":" + slot.toString() + ":" + proposer.toString(); + let h = 2166136261; + for (let i = 0; i < baseSeed.length; i++) { + h ^= baseSeed.charCodeAt(i); + h = Math.imul(h, 16777619); + } + for (let i = pool.length - 1; i > 0; i--) { + h ^= i + 0x9e3779b9; + h = Math.imul(h, 16777619); + const j = Math.abs(h) % (i + 1); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + return pool.slice(0, Math.min(voteCount, pool.length)); +} + +export function chooseProposer() { + const count = getValidatorCount(); + let proposer = randomInt(0, count - 1); + while (count > 1 && proposer === state.previousProposer) proposer = randomInt(0, count - 1); + state.previousProposer = proposer; + return proposer; +} + +export function createSlotPlan(opts = {}) { + const count = getValidatorCount(); + const proposer = opts.proposer != null ? opts.proposer : chooseProposer(); + const participationRate = opts.participationRate != null ? opts.participationRate : randomInt(82, 99); + const voteCount = Math.max(0, Math.round((participationRate / 100) * (count - 1))); + + state.targetVoters = makeDeterministicVoters(state.currentSlot, proposer, voteCount, count, opts.seed); + state.currentVoters = new Set(); + state.voteCursor = 0; + state.nextVoteAt = performance.now() + randomInt(400, 800); + + for (const v of state.validators) { + v.status = v.id === proposer ? "proposer" : "idle"; + } + + if (!state.blocks.some((b) => b.slot === state.currentSlot)) { + addBlock(state.currentSlot, false, { + proposer, + participationRate, + root: opts.root || "", + checkpoint: { + sourceSlot: state.justifiedCheckpoint?.slot ?? Math.max(0, state.currentSlot - 2), + targetSlot: Math.max(0, state.currentSlot - 1), + headSlot: state.currentSlot + } + }); + } +} + +export function addBlock(slot, missed = false, opts = {}) { + const hash = opts.root ? truncateRoot(opts.root) : randomHash(slot); + const proposer = opts.proposer != null ? opts.proposer : chooseProposer(); + const participationRate = opts.participationRate != null ? opts.participationRate : randomInt(82, 99); + + const checkpoint = opts.checkpoint || { + sourceSlot: Math.max(0, slot - 2), + targetSlot: Math.max(0, slot - 1), + headSlot: slot + }; + + const block = { + slot, + hash, + proposer, + participation: participationRate, + missed, + checkpoint, + createdAt: performance.now(), + synthetic: !!opts.synthetic, + countMissed: opts.countMissed !== false, + flashUntil: 0 + }; + + state.blocks = state.blocks.filter((b) => b.slot !== slot).concat(block); + state.blocks.sort((a, b) => a.slot - b.slot); + if (missed && block.countMissed) state.missedSlots += 1; +} + +export function getBlockStatus(block) { + if (block.missed) return "MISSED"; + if (state.finalizedCheckpoint && block.slot <= state.finalizedCheckpoint.slot) return "FINALIZED"; + if (state.justifiedCheckpoint && block.slot <= state.justifiedCheckpoint.slot) return "JUSTIFIED"; + return "HEAD"; +} + +export function findLatestByStatus(status) { + for (let i = state.blocks.length - 1; i >= 0; i--) { + const b = state.blocks[i]; + if (getBlockStatus(b) === status) return b; + } + return null; +} + +export function onNewSlot() { + state.currentSlot += 1; + state.epoch = Math.floor(state.currentSlot / 32); + state.slotStartedAt = performance.now(); + state.proposer = chooseProposer(); + state.participationRate = randomInt(82, 99); + createSlotPlan({ proposer: state.proposer, participationRate: state.participationRate, seed: `${state.currentSlot}` }); + addBlock(state.currentSlot, Math.random() < 0.06); + hydrateHistory(); +} + +export function hydrateHistory() { + state.finalizedHistory = state.blocks + .filter((b) => !b.missed && b.slot <= state.currentSlot - 2) + .slice(-12) + .map((b) => b.slot) + .reverse(); +} + +export function markFinalized(slot) { + const block = state.blocks.find((b) => b.slot === slot && !b.missed); + if (block) { + block.flashUntil = performance.now() + 600; + } + if (!state.finalizedHistory.includes(slot)) { + state.finalizedHistory.unshift(slot); + state.finalizedHistory = state.finalizedHistory.slice(0, 12); + } +} + +export function applyHead(slot, proposer, root) { + if (slot == null) return; + + if (slot < state.currentSlot) { + state.blocks = state.blocks.filter((b) => b.slot <= slot); + state.finalizedHistory = state.finalizedHistory.filter((s) => s <= slot); + state.missedSlots = state.blocks.filter((b) => b.missed && !b.synthetic).length; + state.currentSlot = slot - 1; + } + + if (slot === state.currentSlot) { + const existing = state.blocks.find((b) => b.slot === slot); + if (existing) { + if (proposer != null) existing.proposer = proposer; + if (root) existing.hash = truncateRoot(root); + existing.checkpoint = { + sourceSlot: state.justifiedCheckpoint?.slot ?? Math.max(0, slot - 2), + targetSlot: Math.max(0, slot - 1), + headSlot: slot + }; + existing.createdAt = performance.now(); + } + return; + } + + for (let s = state.currentSlot + 1; s < slot; s++) { + state.proposer = chooseProposer(); + state.participationRate = randomInt(82, 99); + addBlock(s, true, { countMissed: false, synthetic: true }); + } + + state.currentSlot = slot; + state.epoch = Math.floor(slot / 32); + state.slotStartedAt = performance.now(); + state.proposer = proposer != null ? proposer : chooseProposer(); + state.previousProposer = state.proposer; + state.participationRate = randomInt(82, 99); + createSlotPlan({ proposer: state.proposer, participationRate: state.participationRate, seed: `${slot}:${root || ""}`, root: root || "" }); +} + +export function applyFinalityCheckpoint(kind, root, slot) { + if (slot == null) return; + const cp = { root: truncateRoot(root), slot }; + if (kind === "justified") state.justifiedCheckpoint = cp; + if (kind === "finalized") { + const prev = state.finalizedCheckpoint ? state.finalizedCheckpoint.slot : null; + state.finalizedCheckpoint = cp; + if (prev == null || slot > prev) markFinalized(slot); + } +} + +export function getPhase(now) { + const slotMs = currentSlotMs(); + const elapsed = (now - state.slotStartedAt + slotMs) % slotMs; + let cursor = 0; + for (const phase of PHASES) { + const duration = phase.ratio * slotMs; + if (elapsed >= cursor && elapsed < cursor + duration) { + return { id: phase.id, progress: (elapsed - cursor) / duration, elapsed }; + } + cursor += duration; + } + return { id: "merge", progress: 1, elapsed }; +} + +export function updateVotes(now) { + const phase = state.currentPhase; + if (phase !== "vote") return; + while (state.voteCursor < state.targetVoters.length && now >= state.nextVoteAt) { + const id = state.targetVoters[state.voteCursor]; + state.voteCursor += 1; + state.currentVoters.add(id); + if (state.validators[id].status !== "proposer") state.validators[id].status = "voted"; + + state.particles.push({ validatorId: id, startAt: now, duration: 1450, kind: "attestation" }); + state.voteRings.push({ validatorId: id, startAt: now, duration: 1300 }); + + state.nextVoteAt = now + randomInt(900, 1300); + } +} diff --git a/web/src/state.js b/web/src/state.js new file mode 100644 index 0000000..80afa65 --- /dev/null +++ b/web/src/state.js @@ -0,0 +1,74 @@ +export const DEMO_SLOT_MS = 8000; +export const LIVE_SLOT_MS = 4000; +export const DEFAULT_VALIDATOR_COUNT = 24; + +export const PHASES = [ + { id: "propose", label: "PROPOSE", icon: "📡", ratio: 0.20, desc: "Proposer broadcasts block" }, + { id: "vote", label: "VOTE", icon: "🗳️", ratio: 0.45, desc: "Validators cast Head + FFG votes" }, + { id: "confirm", label: "CONFIRM", icon: "⚡", ratio: 0.20, desc: "Quorum reaches two thirds" }, + { id: "merge", label: "MERGE", icon: "🔗", ratio: 0.15, desc: "View merge enters canonical chain" } +]; + +export const COLORS = { + cyan: "#00ffcc", + purple: "#7b61ff", + blue: "#3b82f6", + amber: "#f59e0b", + green: "#22c55e", + red: "#ef4444", + grey: "#2a2a3a", + text: "#e2e8f0", + muted: "#64748b", + border: "#1a1a2e", + surface: "#0d0d14" +}; + +export const peers = [ + { name: "LEAN", lang: "Go", status: "online", self: true }, + { name: "ZEAM", lang: "Zig", status: "online", self: false }, + { name: "REAM", lang: "Rust", status: "syncing", self: false }, + { name: "LANTERN", lang: "C", status: "online", self: false }, + { name: "QLEAN", lang: "C++", status: "offline", self: false } +]; + +export const state = { + currentSlot: 100, + epoch: 3, + slotStartedAt: performance.now(), + lastFrame: performance.now(), + mode: localStorage.getItem("leanviz_mode") || "demo", + currentPhase: "propose", + phaseProgress: 0, + participationRate: 0, + missedSlots: 0, + validatorCount: DEFAULT_VALIDATOR_COUNT, + blocks: [], + finalizedHistory: [], + proposer: 0, + previousProposer: null, + justifiedCheckpoint: null, + finalizedCheckpoint: null, + currentVoters: new Set(), + targetVoters: [], + voteCursor: 0, + nextVoteAt: 0, + validators: Array.from({ length: DEFAULT_VALIDATOR_COUNT }, (_, i) => ({ id: i, status: "idle", active: true })), + particles: [], + voteRings: [], + live: { + beaconUrl: localStorage.getItem("leanviz_beacon_url") || "http://localhost:5052", + metricsUrl: localStorage.getItem("leanviz_metrics_url") || "http://localhost:9090", + apiNamespace: localStorage.getItem("leanviz_api_ns") || "lean", + eventSource: null, + restTimer: null, + watchdogTimer: null, + lastEventAt: 0, + lastEventType: "", + lastRestSuccessAt: 0, + lastMetricsSuccessAt: 0, + lastError: "", + peerCount: 0, + eventsSeen: 0, + connected: false + } +}; diff --git a/web/src/utils.js b/web/src/utils.js new file mode 100644 index 0000000..04f0e4b --- /dev/null +++ b/web/src/utils.js @@ -0,0 +1,66 @@ +export function formatSince(ts) { + if (!ts) return "never"; + const deltaMs = Math.max(0, Date.now() - ts); + const s = Math.floor(deltaMs / 1000); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + const rs = s % 60; + if (m < 60) return `${m}m ${rs}s ago`; + const h = Math.floor(m / 60); + return `${h}h ${(m % 60)}m ago`; +} + +export function randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export function randomHash(slot) { + const hex = (Math.imul(slot, 2654435761) >>> 0).toString(16).padStart(8, "0"); + const tail = (Math.random() * 0xffffffff >>> 0).toString(16).padStart(8, "0"); + return `0x${hex}${tail}...`; +} + +export function truncateRoot(root) { + if (!root || typeof root !== "string") return "0x-"; + return root.length > 13 ? `${root.slice(0, 12)}...` : root; +} + +export function asNumber(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const n = Number(value); + if (Number.isFinite(n)) return n; + } + return null; +} + +export function pickFirstNumber(obj, keys, seen = new Set()) { + if (!obj || typeof obj !== "object" || seen.has(obj)) return null; + seen.add(obj); + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const n = asNumber(obj[key]); + if (n != null) return n; + } + } + const values = Array.isArray(obj) ? obj : Object.values(obj); + for (const v of values) { + const n = pickFirstNumber(v, keys, seen); + if (n != null) return n; + } + return null; +} + +export function pickFirstString(obj, keys, seen = new Set()) { + if (!obj || typeof obj !== "object" || seen.has(obj)) return null; + seen.add(obj); + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(obj, key) && typeof obj[key] === "string") return obj[key]; + } + const values = Array.isArray(obj) ? obj : Object.values(obj); + for (const v of values) { + const s = pickFirstString(v, keys, seen); + if (typeof s === "string") return s; + } + return null; +} diff --git a/web/styles/main.css b/web/styles/main.css new file mode 100644 index 0000000..ab32f94 --- /dev/null +++ b/web/styles/main.css @@ -0,0 +1,757 @@ + :root { + --bg: #050508; + --surface: #0d0d14; + --border: #1a1a2e; + --cyan: #00ffcc; + --purple: #7b61ff; + --blue: #3b82f6; + --amber: #f59e0b; + --green: #22c55e; + --red: #ef4444; + --grey: #2a2a3a; + --text: #e2e8f0; + --muted: #64748b; + } + + * { box-sizing: border-box; } + + body { + margin: 0; + min-height: 100vh; + color: var(--text); + background: + radial-gradient(1200px 700px at 80% -10%, rgba(59, 130, 246, 0.09), transparent 55%), + radial-gradient(900px 500px at 2% 30%, rgba(123, 97, 255, 0.08), transparent 55%), + radial-gradient(800px 450px at 85% 85%, rgba(0, 255, 204, 0.07), transparent 55%), + var(--bg); + font-family: "Syne", sans-serif; + overflow-x: hidden; + } + + body::after { + content: ""; + pointer-events: none; + position: fixed; + inset: 0; + z-index: 50; + opacity: 0.07; + background: repeating-linear-gradient( + 180deg, + rgba(255, 255, 255, 0.65) 0, + rgba(255, 255, 255, 0.65) 1px, + transparent 1px, + transparent 4px + ); + } + + .app { + width: min(1680px, calc(100vw - 24px)); + margin: 12px auto; + border: 1px solid var(--border); + background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.005)); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.55); + backdrop-filter: blur(4px); + } + + .topbar { + min-height: 52px; + padding: 10px 12px; + background: var(--surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + } + + .peer-group, .counter-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .peer-pill { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 20px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.03); + font-family: "Space Mono", monospace; + font-size: 10px; + color: var(--muted); + padding: 5px 10px; + letter-spacing: 0.3px; + transition: border-color 0.35s ease, box-shadow 0.35s ease; + } + + .peer-pill.self { + border-color: rgba(0,255,204,0.75); + color: #cffef4; + box-shadow: 0 0 14px rgba(0,255,204,0.18); + } + + .dot { + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; + background: var(--grey); + } + + .dot.online, .dot.syncing { + animation: dotPulse 2s ease-in-out infinite; + } + + .dot.online { background: var(--green); } + .dot.syncing { background: var(--amber); } + + .badge { + border: 1px solid transparent; + border-radius: 12px; + padding: 3px 9px; + font-family: "Space Mono", monospace; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.35px; + white-space: nowrap; + } + + .badge.pq { + border-color: rgba(123,97,255,0.52); + color: #baaaff; + background: rgba(123,97,255,0.12); + } + + .badge.live { + border-color: rgba(0,255,204,0.55); + color: #aefcea; + background: rgba(0,255,204,0.12); + animation: breathe 2s ease-in-out infinite; + } + + .badge.live.warn { + border-color: rgba(239,68,68,0.5); + color: #ffc0c0; + background: rgba(239,68,68,0.14); + animation: none; + } + + .icon-btn { + border: 1px solid var(--border); + border-radius: 10px; + background: rgba(255,255,255,0.03); + color: var(--text); + width: 30px; + height: 30px; + cursor: pointer; + font-size: 14px; + line-height: 1; + transition: all 0.25s ease; + } + + .icon-btn:hover { + border-color: rgba(0,255,204,0.5); + box-shadow: 0 0 12px rgba(0,255,204,0.18); + } + + .warn-banner { + display: none; + align-items: center; + gap: 8px; + padding: 7px 12px; + border-bottom: 1px solid rgba(239,68,68,0.45); + color: #ffc9c9; + background: rgba(239,68,68,0.12); + font-family: "Space Mono", monospace; + font-size: 11px; + letter-spacing: 0.25px; + } + + .warn-banner.show { + display: flex; + } + + .conn-strip { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + padding: 7px 12px; + border-bottom: 1px solid var(--border); + background: rgba(9, 11, 18, 0.95); + } + + .conn-item { + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px 8px; + background: rgba(255,255,255,0.02); + min-height: 42px; + } + + .conn-item .k { + display: block; + font-family: "Space Mono", monospace; + font-size: 8px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.8px; + } + + .conn-item .v { + display: block; + margin-top: 2px; + font-family: "Space Mono", monospace; + font-size: 11px; + color: #d6e8ff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .settings-panel { + display: none; + border-bottom: 1px solid var(--border); + background: rgba(13,13,20,0.97); + padding: 10px 12px; + } + + .settings-panel.open { + display: block; + } + + .settings-grid { + display: grid; + grid-template-columns: 1fr 1fr auto auto; + gap: 8px; + align-items: end; + } + + .field label { + display: block; + margin-bottom: 4px; + font-family: "Space Mono", monospace; + font-size: 9px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 1px; + } + + .field input, .field select { + width: 100%; + height: 34px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.03); + color: var(--text); + padding: 0 10px; + font-family: "Space Mono", monospace; + font-size: 11px; + } + + .btn { + height: 34px; + border-radius: 8px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.05); + color: var(--text); + padding: 0 11px; + cursor: pointer; + font-family: "Space Mono", monospace; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.6px; + } + + .btn.primary { + border-color: rgba(0,255,204,0.5); + color: #b9fff2; + background: rgba(0,255,204,0.12); + } + + .debug { + margin-top: 8px; + border-top: 1px solid var(--border); + padding-top: 7px; + } + + .debug summary { + cursor: pointer; + font-family: "Space Mono", monospace; + font-size: 10px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.8px; + } + + .debug-grid { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + } + + .debug-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 6px; + background: rgba(255,255,255,0.02); + } + + .debug-card .k { + font-family: "Space Mono", monospace; + font-size: 8px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.8px; + } + + .debug-card .v { + margin-top: 2px; + font-family: "Space Mono", monospace; + font-size: 12px; + color: #cde2ff; + } + + .counter { + min-width: 74px; + padding: 4px 6px; + border-left: 1px solid var(--border); + } + + .counter-label { + display: block; + font-family: "Space Mono", monospace; + font-size: 8px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.9px; + } + + .counter-value { + display: block; + font-family: "Space Mono", monospace; + font-size: 13px; + font-weight: 700; + margin-top: 2px; + color: #b7c6db; + } + + .counter-value.slot { color: var(--cyan); } + + .main { + display: grid; + grid-template-columns: 290px 1fr 350px; + gap: 0; + min-height: 640px; + border-bottom: 1px solid var(--border); + } + + .panel { + background: rgba(13,13,20,0.78); + border-right: 1px solid var(--border); + padding: 16px 14px; + position: relative; + } + + .panel:last-child { border-right: 0; } + + .panel h2 { + margin: 0 0 10px; + font-size: 14px; + letter-spacing: 0.7px; + font-weight: 700; + text-transform: uppercase; + } + + .panel-sub { + margin: 0; + font-size: 11px; + color: var(--muted); + letter-spacing: 0.5px; + text-transform: uppercase; + font-family: "Space Mono", monospace; + } + + .slot-head { + margin: 14px 0 10px; + font-family: "Space Mono", monospace; + font-size: 33px; + color: var(--cyan); + letter-spacing: 0.8px; + text-shadow: 0 0 12px rgba(0,255,204,0.3); + } + + .phase-list { + margin-top: 14px; + display: grid; + gap: 8px; + position: relative; + } + + .phase-item { + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 10px; + background: rgba(255,255,255,0.014); + transition: all 0.35s ease; + } + + .phase-top { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.35px; + } + + .phase-name { + font-family: "Space Mono", monospace; + color: #95a9c7; + font-size: 12px; + } + + .phase-desc { + margin-top: 4px; + font-size: 10px; + color: var(--muted); + line-height: 1.3; + } + + .phase-progress { + margin-top: 6px; + height: 5px; + border-radius: 999px; + background: rgba(255,255,255,0.06); + overflow: hidden; + } + + .phase-progress > span { + display: block; + height: 100%; + width: 0%; + background: linear-gradient(90deg, rgba(0,255,204,0.2), var(--cyan)); + transition: width 0.1s linear; + } + + .phase-item.active { + border-color: rgba(0,255,204,0.45); + box-shadow: 0 0 16px rgba(0,255,204,0.14); + background: rgba(0,255,204,0.05); + } + + .phase-item.active .phase-name, + .phase-item.active .phase-icon { + color: var(--cyan); + text-shadow: 0 0 10px rgba(0,255,204,0.28); + } + + .phase-item.done { + border-color: rgba(34,197,94,0.3); + background: rgba(34,197,94,0.07); + opacity: 0.86; + } + + .phase-item.done .phase-name, + .phase-item.done .phase-icon { + color: var(--green); + } + + .phase-item.pending { + opacity: 0.58; + filter: saturate(0.65); + } + + .slot-foot { + margin-top: 14px; + border-top: 1px solid var(--border); + padding-top: 10px; + font-family: "Space Mono", monospace; + font-size: 12px; + color: #a5b6cf; + line-height: 1.45; + } + + .slot-foot .countdown { + font-size: 18px; + color: #d0fff5; + text-shadow: 0 0 8px rgba(0,255,204,0.25); + display: block; + margin-bottom: 3px; + } + + .canvas-panel { + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px; + background: rgba(13,13,20,0.64); + border-right: 1px solid var(--border); + } + + .canvas-title { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; + margin-bottom: 6px; + } + + .canvas-title h2 { + margin: 0; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.7px; + } + + .canvas-title span { + font-family: "Space Mono", monospace; + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.8px; + } + + .canvas-wrap { + position: relative; + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + background: linear-gradient(180deg, rgba(0,0,0,0.18), rgba(255,255,255,0.01)); + } + + canvas { + width: 100%; + height: 100%; + display: block; + } + + #chainCanvas { height: 520px; } + #validatorCanvas { height: 420px; } + + .net-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; + } + + .net-stat { + border: 1px solid var(--border); + border-radius: 9px; + padding: 8px 7px; + background: rgba(255,255,255,0.02); + } + + .net-stat span { + display: block; + font-family: "Space Mono", monospace; + } + + .net-stat .k { + font-size: 9px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.7px; + } + + .net-stat .v { + font-size: 14px; + color: #cee8ff; + margin-top: 3px; + } + + .bottom { + background: rgba(5,5,8,0.9); + border-top: 1px solid var(--border); + padding: 12px; + display: grid; + grid-template-columns: 220px 220px 1fr 300px; + gap: 10px; + align-items: stretch; + } + + .cp-box { + border-radius: 10px; + padding: 10px; + background: rgba(255,255,255,0.02); + min-height: 112px; + } + + .cp-box .label { + font-family: "Space Mono", monospace; + font-size: 9px; + letter-spacing: 1px; + text-transform: uppercase; + } + + .cp-box .slot { + font-family: "Space Mono", monospace; + font-size: 28px; + margin-top: 3px; + font-weight: 700; + } + + .cp-box .hash { + font-family: "Space Mono", monospace; + font-size: 10px; + color: #8a9cb6; + margin-top: 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .cp-box.justified { + border: 1px solid rgba(245,158,11,0.4); + background: rgba(245,158,11,0.05); + } + + .cp-box.justified .label, + .cp-box.justified .slot { color: var(--amber); } + + .cp-box.finalized { + border: 1px solid rgba(34,197,94,0.44); + background: rgba(34,197,94,0.05); + } + + .cp-box.finalized .label, + .cp-box.finalized .slot { color: var(--green); } + + .gap { + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px; + background: rgba(255,255,255,0.02); + } + + .gap .title { + font-family: "Space Mono", monospace; + font-size: 11px; + letter-spacing: 0.5px; + color: #9bb0d0; + } + + .gap-bar { + margin-top: 8px; + height: 6px; + border-radius: 999px; + overflow: hidden; + background: rgba(255,255,255,0.08); + } + + .gap-fill { + height: 100%; + width: 0%; + transition: width 0.8s ease, background-color 0.4s ease; + background: var(--green); + box-shadow: 0 0 12px rgba(34,197,94,0.5); + } + + .gap .helper { + margin-top: 8px; + color: var(--muted); + font-size: 11px; + letter-spacing: 0.2px; + } + + .chip-wrap { + margin-top: 10px; + display: flex; + gap: 6px; + flex-wrap: wrap; + } + + .chip { + min-width: 30px; + height: 30px; + border-radius: 9px; + border: 1px solid rgba(34,197,94,0.35); + background: rgba(34,197,94,0.08); + color: #6ef3a0; + font-family: "Space Mono", monospace; + font-size: 11px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .bottom-metrics { + border-left: 1px solid var(--border); + padding-left: 10px; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + align-content: start; + } + + .bottom-metric { + border: 1px solid var(--border); + border-radius: 9px; + padding: 8px; + background: rgba(255,255,255,0.02); + } + + .bottom-metric .v { + font-family: "Space Mono", monospace; + font-size: 16px; + color: #d3e7ff; + font-weight: 700; + } + + .bottom-metric .k { + margin-top: 2px; + font-family: "Space Mono", monospace; + font-size: 8px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--muted); + } + + @keyframes dotPulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.58; transform: scale(1.1); } + } + + @keyframes breathe { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.62; } + } + + @media (max-width: 1300px) { + .settings-grid { + grid-template-columns: 1fr; + } + + .conn-strip { + grid-template-columns: 1fr 1fr; + } + + .debug-grid { + grid-template-columns: 1fr 1fr; + } + + .main { + grid-template-columns: 1fr; + } + + .panel, + .canvas-panel { + border-right: 0; + border-bottom: 1px solid var(--border); + } + + #chainCanvas { height: 420px; } + #validatorCanvas { height: 360px; } + + .bottom { + grid-template-columns: 1fr; + } + + .bottom-metrics { border-left: 0; padding-left: 0; } + } +