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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions src/memory/zeroGStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Promise<void>>();

async putPolicy(policy: Policy): Promise<void> {
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<Policy | null> {
Expand All @@ -78,8 +85,36 @@ export class ZeroGStore implements Store {

async appendDecision(decision: Decision): Promise<void> {
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<void> {
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: {
Expand Down
16 changes: 16 additions & 0 deletions web/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
'<span class="anchor-pill anchor-pill-pending" title="Anchoring decision on 0G Galileo testnet — typically 5-15s">' +
'<span class="anchor-pill-label">0G</span>' +
'<span class="anchor-pill-hash">anchoring</span>' +
"</span>"
);
}

/**
* Returns trusted HTML that is safe to insert via `innerHTML`.
*
Expand Down
151 changes: 130 additions & 21 deletions web/src/lib/policies.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

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";
Expand Down Expand Up @@ -95,19 +106,7 @@ export async function loadPolicies(): Promise<void> {
return;
}
list.innerHTML = r.data
.map(
(p) => `
<div class="policy-card">
<div class="policy-card-row">
<span class="policy-card-owner">${escapeHtml(p.owner)}</span>
<span class="policy-card-version">v${p.version}</span>
</div>
<div class="policy-card-id">${escapeHtml(p.id)}</div>
<div class="policy-card-rules">${escapeHtml(formatRules(p.rules))}</div>
<div class="policy-card-anchor">${anchorPillHtml(p.anchor)}</div>
</div>
`,
)
.map((p) => renderPolicyCard(p))
.join("");
select.innerHTML =
'<option value="">— select a policy —</option>' +
Expand All @@ -119,8 +118,78 @@ export async function loadPolicies(): Promise<void> {
.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 `
<div class="policy-card" data-policy-id="${escapeHtml(p.id)}">
<div class="policy-card-row">
<span class="policy-card-owner">${escapeHtml(p.owner)}</span>
<span class="policy-card-version">v${p.version}</span>
</div>
<div class="policy-card-id">${escapeHtml(p.id)}</div>
<div class="policy-card-rules">${escapeHtml(formatRules(p.rules))}</div>
<div class="policy-card-anchor">${pillHtml}</div>
</div>
`;
}

/**
* 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 `
<div class="policy-card policy-card-skeleton" aria-busy="true">
<div class="policy-card-row">
<span class="skeleton skeleton-text skeleton-owner"></span>
<span class="skeleton skeleton-text skeleton-version"></span>
</div>
<div class="skeleton skeleton-text skeleton-id"></div>
<div class="skeleton skeleton-text skeleton-rules"></div>
<div class="policy-card-anchor"><span class="skeleton skeleton-pill"></span></div>
</div>
`;
}

/**
* 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<void> {
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<Policy>("POST", "/policies", {
owner: TREASURY,
rules: {
maxTransferEth: 1,
Expand All @@ -130,16 +199,56 @@ export async function loadDemo(): Promise<void> {
},
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<void> {
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<Policy>("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();
}
78 changes: 78 additions & 0 deletions web/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
* <span>, 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);
Expand Down
Loading