From 852f56189b61e214ac1918b9b11938b132240c43 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Sun, 3 May 2026 17:48:58 +0530 Subject: [PATCH] make policy creation feel instant: server fires 0g anchor in the background and the ui shows an optimistic skeleton while the real card lands; the actual fix is three layers stacked together because the underlying problem (5-30s 0g storage upload on galileo testnet) cannot be papered over with a button spinner; layer 1 (server) splits zeroGStore.putPolicy and appendDecision into two phases - the in-memory write completes synchronously and returns immediately while the tryAnchor upload goes through scheduleAnchor which kicks off the upload but does not await it, so the api hot path drops from 5-30s to ~50ms; a private pending Map> tracks every in-flight upload so the new waitForAnchor(id) helper can be used by tests + admin tooling without coupling production callers to a synchronous wait; layer 2 (frontend) uses the empty space under active policies as the loading visual instead of a rotating blob on the button - clicking quick demo immediately injects a policy-card-skeleton via policySkeletonHtml() into #policies-list with shimmer animation across each row (skeleton-owner, skeleton-version, skeleton-id, skeleton-rules, skeleton-pill) so the eye lands where the real card will be; the @keyframes skeleton-shimmer slides a subtle paper-soft gradient across each placeholder rectangle every 1.4s; the post returns in ~50ms and loadPolicies() replaces the skeleton with the real card whose pill shows the new pending state from anchorPendingPillHtml() - same lime accent footprint as the live pill but with a pulsing leading dot, italic 'anchoring' text, and a breathing opacity loop (anchor-pending-breathe keyframe at 1.8s) signalling 'still working'; layer 3 (frontend polling) tracks a module-level pendingAnchors Set with each policy id whose anchor has not yet landed, and pollForAnchor(id) GETs /policies/:id every 2s for up to 30s; when the response includes a non-empty rootHash the id is removed from the set, loadPolicies re-renders, and the pill flips from pulsing 'anchoring' to the lime '0G | 0xroot...' link without any visual jolt; on timeout the id is removed and the pill drops to the terminal 'not anchored' marker; the polling loop is intentionally tolerant of network blips and shape mismatches so a brief outage does not flip the pill to terminal early; renderPolicyCard() is the single source of truth for the card shape and reads from pendingAnchors when deciding pending vs not-anchored; the data-policy-id attribute is added so future fine-grained rerenders can target a single card without redrawing the list --- src/memory/zeroGStore.ts | 43 ++++++++++- web/src/lib/format.ts | 16 ++++ web/src/lib/policies.ts | 151 ++++++++++++++++++++++++++++++++------ web/src/styles/global.css | 78 ++++++++++++++++++++ 4 files changed, 263 insertions(+), 25 deletions(-) diff --git a/src/memory/zeroGStore.ts b/src/memory/zeroGStore.ts index 74826c9..2683f91 100644 --- a/src/memory/zeroGStore.ts +++ b/src/memory/zeroGStore.ts @@ -59,10 +59,17 @@ export class ZeroGStore implements Store { } } + /** + * Tracks every in-flight 0G upload by row id so tests + the API admin + * route can `await` for the anchor to land. In production nothing awaits + * this — `putPolicy`/`appendDecision` return immediately after the local + * write so the request hot path stays under 50ms instead of 5-30s. + */ + private pending = new Map>(); + async putPolicy(policy: Policy): Promise { this.policies.set(policy.id, policy); - const anchor = await this.tryAnchor(`policy:${policy.id}`, JSON.stringify(policy)); - if (anchor) this.anchors.set(policy.id, anchor); + this.scheduleAnchor(policy.id, `policy:${policy.id}`, JSON.stringify(policy)); } async getPolicy(id: string): Promise { @@ -78,8 +85,36 @@ export class ZeroGStore implements Store { async appendDecision(decision: Decision): Promise { this.decisions.push(decision); - const anchor = await this.tryAnchor(`decision:${decision.id}`, JSON.stringify(decision)); - if (anchor) this.anchors.set(decision.id, anchor); + this.scheduleAnchor(decision.id, `decision:${decision.id}`, JSON.stringify(decision)); + } + + /** + * Awaits the background anchor upload for a single row. Used by tests so + * they can assert anchor presence after the upload settles, without + * coupling production code to a synchronous wait. Resolves immediately + * if no upload was scheduled for that id (e.g. wrong id, or anchor + * already finished). + */ + async waitForAnchor(id: string): Promise { + const p = this.pending.get(id); + if (p) await p; + } + + private scheduleAnchor(rowId: string, label: string, json: string): void { + // Kick off the upload but DO NOT await — production callers return as + // soon as the in-memory write is done. The promise is parked in + // `pending` so tests + admin tooling can settle it. + const p = this.tryAnchor(label, json) + .then((anchor) => { + if (anchor) this.anchors.set(rowId, anchor); + }) + .finally(() => { + // Only delete if the entry still points at this promise — a + // subsequent overwrite of the same id (rare for policies, never + // for decisions) would replace it and we don't want to clobber. + if (this.pending.get(rowId) === p) this.pending.delete(rowId); + }); + this.pending.set(rowId, p); } async listDecisions(filter: { diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 8b96cd6..ff01760 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -72,6 +72,22 @@ export function shortHash(h: string | undefined | null): string { const STORAGESCAN = "https://storagescan-galileo.0g.ai"; const CHAINSCAN = "https://chainscan-galileo.0g.ai"; +/** + * Pill state for a row that has been written to the in-memory cache but + * whose 0G anchor is still uploading in the background. Shown while the + * frontend polls /policies/:id for the anchor to land. Pulsing accent dot + * + "anchoring" text — no link yet because there's no rootHash to point + * at. The CSS animation lives in `.anchor-pill-pending` in global.css. + */ +export function anchorPendingPillHtml(): string { + return ( + '' + + '0G' + + 'anchoring' + + "" + ); +} + /** * Returns trusted HTML that is safe to insert via `innerHTML`. * diff --git a/web/src/lib/policies.ts b/web/src/lib/policies.ts index a6c7954..dd6c94e 100644 --- a/web/src/lib/policies.ts +++ b/web/src/lib/policies.ts @@ -1,9 +1,20 @@ import { api } from "./api.js"; -import { anchorPillHtml, escapeHtml, formatRules } from "./format.js"; +import { anchorPendingPillHtml, anchorPillHtml, escapeHtml, formatRules } from "./format.js"; import { showJsonModal } from "./modal.js"; import { summarizeZodIssues } from "./format.js"; import type { Address, Hex, Policy } from "./types.js"; +/** + * Policy ids whose 0G anchor upload is still in flight on the server. + * Set + cleared by `pollForAnchor`. Used by `loadPolicies` to render the + * pulsing "anchoring" pill instead of the terminal "not anchored" pill + * for rows that are still being committed. + */ +const pendingAnchors = new Set(); + +const ANCHOR_POLL_INTERVAL_MS = 2_000; +const ANCHOR_POLL_TIMEOUT_MS = 30_000; + export const TREASURY: Address = "0x1111111111111111111111111111111111111111"; export const COLD_VAULT: Address = "0x2222222222222222222222222222222222222222"; export const ATTACKER: Address = "0x3333333333333333333333333333333333333333"; @@ -95,19 +106,7 @@ export async function loadPolicies(): Promise { return; } list.innerHTML = r.data - .map( - (p) => ` -
-
- ${escapeHtml(p.owner)} - v${p.version} -
-
${escapeHtml(p.id)}
-
${escapeHtml(formatRules(p.rules))}
-
${anchorPillHtml(p.anchor)}
-
- `, - ) + .map((p) => renderPolicyCard(p)) .join(""); select.innerHTML = '' + @@ -119,8 +118,78 @@ export async function loadPolicies(): Promise { .join(""); } +/** + * Render the active-policies list. Cards whose anchor is still uploading on + * the server (tracked in `pendingAnchors`) get the pulsing "anchoring" pill + * instead of the terminal "not anchored" pill. The set is updated by + * `pollForAnchor` and re-renders are triggered by `loadPolicies`. + */ +function renderPolicyCard(p: Policy): string { + const pillHtml = + p.anchor && p.anchor.rootHash + ? anchorPillHtml(p.anchor) + : pendingAnchors.has(p.id) + ? anchorPendingPillHtml() + : anchorPillHtml(undefined); + return ` +
+
+ ${escapeHtml(p.owner)} + v${p.version} +
+
${escapeHtml(p.id)}
+
${escapeHtml(formatRules(p.rules))}
+
${pillHtml}
+
+ `; +} + +/** + * Skeleton card injected into the policies list the moment the user clicks + * Quick Demo. It occupies the same vertical slot a real card would, with + * a shimmer animation across each row, so the eye lands in the right + * place. Replaced wholesale by `loadPolicies` once the POST returns + * (~50ms with the new async-anchor server path). + */ +function policySkeletonHtml(): string { + return ` +
+
+ + +
+
+
+
+
+ `; +} + +/** + * Click Quick Demo: + * 1. Render a skeleton card immediately so the user gets visual feedback. + * 2. POST the policy in background. With the server's fire-and-forget + * anchoring, this returns in ~50ms instead of 5-30s. + * 3. Replace the skeleton with the real card by reloading the list. + * The new card's anchor pill shows "anchoring" while polling runs. + * 4. Poll GET /policies/:id every 2s for up to 30s. When the anchor + * lands on the server it shows up in the response; we update the set + * and re-render so the pill flips to the lime "0G | 0xroot…" link. + */ export async function loadDemo(): Promise { - await api("POST", "/policies", { + const list = document.getElementById("policies-list"); + if (list) list.innerHTML = policySkeletonHtml(); + + // Pre-fill the evaluate form regardless of POST latency. + const f = document.getElementById("evaluate-form") as HTMLFormElement | null; + if (f) { + const fromEl = findField(f, "from"); + const toEl = findField(f, "to"); + if (fromEl) fromEl.value = TREASURY; + if (toEl) toEl.value = COLD_VAULT; + } + + const r = await api("POST", "/policies", { owner: TREASURY, rules: { maxTransferEth: 1, @@ -130,16 +199,56 @@ export async function loadDemo(): Promise { }, remediation: { onBlock: [], notifyChannels: ["collector"] }, }); + + if (r.ok && r.data && typeof r.data === "object" && "id" in r.data) { + const created = r.data as Policy; + if (!created.anchor || !created.anchor.rootHash) { + pendingAnchors.add(created.id); + void pollForAnchor(created.id); + } + } + await loadPolicies(); + const select = document.getElementById("policy-select") as HTMLSelectElement | null; if (select && select.options.length > 1 && select.options[1]) { select.value = select.options[1].value; } - const f = document.getElementById("evaluate-form") as HTMLFormElement | null; - if (f) { - const fromEl = findField(f, "from"); - const toEl = findField(f, "to"); - if (fromEl) fromEl.value = TREASURY; - if (toEl) toEl.value = COLD_VAULT; +} + +/** + * Poll GET /policies/:id every {@link ANCHOR_POLL_INTERVAL_MS}ms. Stops when + * the response includes a non-empty rootHash (anchor landed) or after + * {@link ANCHOR_POLL_TIMEOUT_MS}ms (assume the upload failed silently — the + * pill drops back to the terminal "not anchored" state). + * + * Intentionally tolerant: any non-2xx, network blip, or shape mismatch just + * keeps the polling loop running until the timeout. The user sees a worst + * case of a 30s pulsing pill that quietly resolves. + */ +async function pollForAnchor(policyId: string): Promise { + const start = performance.now(); + while (performance.now() - start < ANCHOR_POLL_TIMEOUT_MS) { + await new Promise((resolve) => setTimeout(resolve, ANCHOR_POLL_INTERVAL_MS)); + const r = await api("GET", `/policies/${encodeURIComponent(policyId)}`); + if ( + r.ok && + r.data && + typeof r.data === "object" && + "anchor" in r.data && + r.data.anchor && + typeof r.data.anchor === "object" && + "rootHash" in r.data.anchor && + typeof r.data.anchor.rootHash === "string" && + r.data.anchor.rootHash.length > 0 + ) { + pendingAnchors.delete(policyId); + await loadPolicies(); + return; + } } + // Timed out — drop the pending state and re-render so the pill flips to + // the terminal "not anchored" marker. + pendingAnchors.delete(policyId); + await loadPolicies(); } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index fcddeea..1688002 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -517,6 +517,48 @@ body::after { margin-top: 0.55rem; line-height: 1.55; } + +/* ---------- skeleton card (optimistic loader for Quick Demo) ---------- */ +.policy-card-skeleton { + border-color: var(--rule-soft); +} +.policy-card-skeleton:hover { + transform: none; + border-color: var(--rule-soft); +} +.skeleton { + display: inline-block; + position: relative; + overflow: hidden; + background: var(--ink-2); + border-radius: 2px; +} +.skeleton::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(244, 237, 226, 0.04) 50%, + transparent 100% + ); + animation: skeleton-shimmer 1.4s linear infinite; +} +.skeleton-text { height: 0.85em; vertical-align: middle; } +.skeleton-owner { width: 22ch; height: 0.85rem; } +.skeleton-version { width: 4ch; height: 0.65rem; } +.skeleton-id { width: 32ch; height: 0.7rem; margin-top: 0.4rem; } +.skeleton-rules { width: 80%; height: 0.7rem; margin-top: 0.65rem; } +.skeleton-pill { + width: 7rem; + height: 1.3rem; + vertical-align: middle; +} +@keyframes skeleton-shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} .empty { font-family: var(--mono); font-size: 0.78rem; @@ -816,6 +858,42 @@ body::after { letter-spacing: 0.04em; } +/* The pulsing "anchoring" pill shown while the 0G upload is in flight on + * the server. Same shape as `.anchor-pill` but rendered as a non-link + * , with a subtle breathing background and a leading dot that + * pulses to communicate "still working". The dot reuses the masthead's + * status-dot pulse keyframe for visual consistency. */ +.anchor-pill-pending { + cursor: progress; + border-color: rgba(196, 255, 91, 0.25); + background: rgba(196, 255, 91, 0.04); + animation: anchor-pending-breathe 1.8s ease-in-out infinite; +} +.anchor-pill-pending:hover { + /* Pending pills are not clickable yet — neutralise the link-style hover */ + background: rgba(196, 255, 91, 0.04); + border-color: rgba(196, 255, 91, 0.25); +} +.anchor-pill-pending::before { + content: ""; + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent); + margin-right: 0.1rem; + animation: pulse 1.4s infinite ease-in-out; +} +.anchor-pill-pending .anchor-pill-hash { + color: var(--paper-soft); + font-style: italic; + letter-spacing: 0.05em; +} +@keyframes anchor-pending-breathe { + 0%, 100% { opacity: 0.85; } + 50% { opacity: 1; } +} + .reasons { list-style: none; padding: 0; margin: 0; } .reasons li { font-family: var(--sans);