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);