From 571631cf58f1b9c3be9d92c549ca86015110badb Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Fri, 23 Feb 2024 18:43:33 +0100 Subject: [PATCH 1/2] Add block sync time estimate --- .../src/routes/popup/home/block-sync.tsx | 43 +------------ .../components/ui/block-sync-status/ewma.ts | 58 ++++++++++++++++++ .../components/ui/block-sync-status/index.tsx | 60 +++++++++++++++++++ .../block-sync-status/sync-progress-hook.ts | 56 +++++++++++++++++ packages/ui/index.ts | 1 + packages/ui/package.json | 2 + pnpm-lock.yaml | 14 +++++ 7 files changed, 194 insertions(+), 40 deletions(-) create mode 100644 packages/ui/components/ui/block-sync-status/ewma.ts create mode 100644 packages/ui/components/ui/block-sync-status/index.tsx create mode 100644 packages/ui/components/ui/block-sync-status/sync-progress-hook.ts diff --git a/apps/extension/src/routes/popup/home/block-sync.tsx b/apps/extension/src/routes/popup/home/block-sync.tsx index aa938a5049..a7ebc8380e 100644 --- a/apps/extension/src/routes/popup/home/block-sync.tsx +++ b/apps/extension/src/routes/popup/home/block-sync.tsx @@ -1,46 +1,9 @@ -import { Progress } from '@penumbra-zone/ui'; -import { motion } from 'framer-motion'; -import { CheckIcon } from '@radix-ui/react-icons'; +import { BlockSyncStatus } from '@penumbra-zone/ui'; import { useSyncProgress } from '../../../hooks/last-block-synced'; export const BlockSync = () => { const { lastBlockHeight, lastBlockSynced } = useSyncProgress(); + if (!lastBlockHeight) return <>; - return ( - - {lastBlockHeight ? ( - // Is syncing ⏳ - lastBlockHeight - lastBlockSynced > 10 ? ( - <> -
-

Syncing blocks...

-
- -

- {lastBlockSynced}/{lastBlockHeight} -

- - ) : ( - // Is synced ✅ - <> -
-
-

Blocks synced

- -
-
- -

- {lastBlockSynced}/{lastBlockHeight} -

- - ) - ) : null} -
- ); + return ; }; diff --git a/packages/ui/components/ui/block-sync-status/ewma.ts b/packages/ui/components/ui/block-sync-status/ewma.ts new file mode 100644 index 0000000000..d6f0056a99 --- /dev/null +++ b/packages/ui/components/ui/block-sync-status/ewma.ts @@ -0,0 +1,58 @@ +/** + * Inspired from: https://github.com/rsocket/ewma/blob/master/index.js + * However, the reason why we have our own code for this is because this library + * was missing types and can only run in nodejs, not a browser environment. + * + * Ewma (Exponential Weighted Moving Average) is used to calculate a smoothed average of a series + * of numbers, where more recent values have a greater weight than older values. This class is + * particularly useful for smoothing out time-series data or calculating an average that needs to + * adapt quickly to changes. The half-life parameter controls how quickly the weights decrease for + * older values. + */ +export class EWMA { + private readonly decay: number; + private ewma: number; + private lastUpdate: number; + + /** + * Initializes a new instance of the Ewma class. + * @param halfLifeMs The half-life in milliseconds, indicating how quickly the influence of a value + * decreases to half its original weight. This parameter directly impacts the speed of convergence + * towards recent values, with lower values making the average more sensitive to recent changes. + * @param initialValue The initial value of the EWMA, defaulted to 0 if not provided. + */ + constructor(halfLifeMs = 10_000, initialValue = 0) { + this.decay = halfLifeMs; + this.ewma = initialValue; + this.lastUpdate = Date.now(); + } + + /** + * Inserts a new value into the EWMA calculation, updating the average based on the elapsed time + * since the last update and the new value's weight. + */ + insert(x: number): void { + const now = Date.now(); + const elapsed = now - this.lastUpdate; + this.lastUpdate = now; + + const weight = Math.pow(2, -elapsed / this.decay); + this.ewma = weight * this.ewma + (1 - weight) * x; + } + + /** + * Resets the EWMA to a specified new value, effectively restarting the calculation as if this + * new value was the first and only value provided. + */ + reset(x: number): void { + this.lastUpdate = Date.now(); + this.ewma = x; + } + + /** + * Returns the current value of the EWMA. + */ + value(): number { + return this.ewma; + } +} diff --git a/packages/ui/components/ui/block-sync-status/index.tsx b/packages/ui/components/ui/block-sync-status/index.tsx new file mode 100644 index 0000000000..170f21f3e0 --- /dev/null +++ b/packages/ui/components/ui/block-sync-status/index.tsx @@ -0,0 +1,60 @@ +import { motion } from 'framer-motion'; +import { CheckIcon } from '@radix-ui/react-icons'; +import { Progress } from '../progress'; +import { useSyncProgress } from './sync-progress-hook'; +import { cn } from '../../../lib/utils'; + +interface BlockSyncProps { + lastBlockHeight: number; + lastBlockSynced: number; +} + +export const BlockSyncStatus = ({ lastBlockHeight, lastBlockSynced }: BlockSyncProps) => { + const { formattedTimeRemaining, confident } = useSyncProgress(lastBlockSynced, lastBlockHeight); + + return ( + + {lastBlockHeight ? ( + // Is syncing ⏳ + lastBlockHeight - lastBlockSynced > 10 ? ( + <> +
+

Syncing blocks...

+
+ +

+ {lastBlockSynced}/{lastBlockHeight} +

+

+ {formattedTimeRemaining} +

+ + ) : ( + // Is synced ✅ + <> +
+
+

Blocks synced

+ +
+
+ +

+ {lastBlockSynced}/{lastBlockHeight} +

+ + ) + ) : null} +
+ ); +}; diff --git a/packages/ui/components/ui/block-sync-status/sync-progress-hook.ts b/packages/ui/components/ui/block-sync-status/sync-progress-hook.ts new file mode 100644 index 0000000000..ba07a9447b --- /dev/null +++ b/packages/ui/components/ui/block-sync-status/sync-progress-hook.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef, useState } from 'react'; +import { EWMA } from './ewma'; +import humanizeDuration from 'humanize-duration'; + +/** + * Custom hook to calculate synchronization speed and estimate the time remaining + * for a synchronization process to complete, using the Exponential Weighted Moving Average (EWMA) + * to smooth the speed calculation. + * + * @returns An object containing: + * - speed: Current speed of synchronization in blocks per second. + * - timeRemaining: Estimated time remaining to complete the synchronization, in seconds. + * - formattedTimeRemaining: Human-readable string representation (e.g., "13 min, 49 sec"). + * - confident: A boolean flag indicating whether the speed calculation is considered reliable. + */ +export const useSyncProgress = ( + lastBlockSynced: number, + lastBlockHeight: number, + syncUpdatesThreshold = 10, // The number of synchronization updates required before the speed calculation is considered reliable +) => { + const ewmaSpeedRef = useRef(new EWMA()); + + const [speed, setSpeed] = useState(0); + const lastSyncedRef = useRef(lastBlockSynced); + const lastUpdateTimeRef = useRef(Date.now()); + const [confident, setConfident] = useState(false); // Tracks confidence in the speed calculation + const [syncUpdates, setSyncUpdates] = useState(0); // Tracks the number of synchronization updates + + useEffect(() => { + const now = Date.now(); + const timeElapsedMs = now - lastUpdateTimeRef.current; + const blocksSynced = lastBlockSynced - lastSyncedRef.current; + + if (timeElapsedMs > 0 && blocksSynced >= 0) { + const instantSpeed = (blocksSynced / timeElapsedMs) * 1000; // Calculate speed in blocks per second + ewmaSpeedRef.current.insert(instantSpeed); + setSpeed(ewmaSpeedRef.current.value()); + setSyncUpdates(prev => prev + 1); // Increment the number of sync updates + } + + lastSyncedRef.current = lastBlockSynced; + lastUpdateTimeRef.current = now; + + // Update confident flag based on the number of sync updates + if (syncUpdates >= syncUpdatesThreshold && !confident) { + setConfident(true); + } + }, [lastBlockSynced, syncUpdates, syncUpdatesThreshold, confident]); + + const blocksRemaining = lastBlockHeight - lastBlockSynced; + const timeRemaining = speed > 0 ? blocksRemaining / speed : Infinity; + const formattedTimeRemaining = + timeRemaining === Infinity ? '' : humanizeDuration(timeRemaining * 1000, { round: true }); + + return { speed, timeRemaining, formattedTimeRemaining, confident }; +}; diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 93679b0275..e8099c0eff 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -1,4 +1,5 @@ export * from './components/ui/back-icon'; +export * from './components/ui/block-sync-status'; export * from './components/ui/button'; export * from './components/ui/card'; export * from './components/ui/copy-to-clipboard'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 9e951fedb4..79e887d2cc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -29,6 +29,7 @@ "clsx": "^2.1.0", "djb2a": "^2.0.0", "framer-motion": "^11.0.5", + "humanize-duration": "^3.31.0", "lucide-react": "^0.331.0", "react-dom": "^18.2.0", "react-json-view": "^1.21.3", @@ -47,6 +48,7 @@ "@storybook/react": "^7.6.16", "@storybook/react-vite": "^7.6.17", "@testing-library/react": "^14.2.1", + "@types/humanize-duration": "^3.27.4", "@types/node": "^20.11.19", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 507e7559f6..619583b173 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -590,6 +590,9 @@ importers: framer-motion: specifier: ^11.0.5 version: 11.0.5(react-dom@18.2.0)(react@18.2.0) + humanize-duration: + specifier: ^3.31.0 + version: 3.31.0 lucide-react: specifier: ^0.331.0 version: 0.331.0(react@18.2.0) @@ -639,6 +642,9 @@ importers: '@testing-library/react': specifier: ^14.2.1 version: 14.2.1(react-dom@18.2.0)(react@18.2.0) + '@types/humanize-duration': + specifier: ^3.27.4 + version: 3.27.4 '@types/node': specifier: ^20.11.19 version: 20.11.19 @@ -6056,6 +6062,10 @@ packages: resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} dev: true + /@types/humanize-duration@3.27.4: + resolution: {integrity: sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==} + dev: true + /@types/inquirer@6.5.0: resolution: {integrity: sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==} dependencies: @@ -10804,6 +10814,10 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} + /humanize-duration@3.31.0: + resolution: {integrity: sha512-fRrehgBG26NNZysRlTq1S+HPtDpp3u+Jzdc/d5A4cEzOD86YLAkDaJyJg8krSdCi7CJ+s7ht3fwRj8Dl+Btd0w==} + dev: false + /iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} From 7984e1f5520b05b40e8e76b5a3c00576d3daea92 Mon Sep 17 00:00:00 2001 From: Gabe Rodriguez Date: Mon, 26 Feb 2024 12:52:48 +0100 Subject: [PATCH 2/2] review updates --- .../ui/block-sync-status/ewma.test.ts | 99 +++++++++++++++++++ .../components/ui/block-sync-status/ewma.ts | 36 ++++++- .../components/ui/block-sync-status/index.tsx | 86 +++++++++------- .../block-sync-status/sync-progress-hook.ts | 4 +- 4 files changed, 180 insertions(+), 45 deletions(-) create mode 100644 packages/ui/components/ui/block-sync-status/ewma.test.ts diff --git a/packages/ui/components/ui/block-sync-status/ewma.test.ts b/packages/ui/components/ui/block-sync-status/ewma.test.ts new file mode 100644 index 0000000000..36e7a49a48 --- /dev/null +++ b/packages/ui/components/ui/block-sync-status/ewma.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EWMA } from './ewma'; + +describe('EWMA', () => { + describe('Constructor Tests', () => { + it('initializes with default parameters correctly', () => { + const ewma = new EWMA(); + expect(ewma.value()).toBe(0); + }); + + it('initializes with custom parameters correctly', () => { + const halfLifeMs = 20000; + const initialValue = 10; + const ewma = new EWMA({ halfLifeMs, initialValue }); + expect(ewma.value()).toBe(initialValue); + }); + }); + + // Copied from original test suite: https://github.com/rsocket/ewma/blob/master/test/index.js + describe('half life', () => { + it('work properly', () => { + let NOW = 10000000; + const clock = { + now: function () { + return NOW; + }, + }; + let e = new EWMA({ halfLifeMs: 1, initialValue: 10, clock }); + let shouldBe = 10; + expect(e.value()).toBe(shouldBe); + NOW++; + + for (let i = 1; i < 100; i++, NOW++) { + shouldBe = shouldBe * 0.5 + i * 0.5; + e.insert(i); + expect(e.value()).toBe(shouldBe); + } + + e.reset(0); + shouldBe = 0; + expect(e.value()).toBe(shouldBe); + NOW += 1; + + for (let i = 1; i < 100; i++, NOW += 1) { + shouldBe = shouldBe * 0.5 + i * 0.5; + e.insert(i); + expect(e.value()).toBe(shouldBe); + } + + e = new EWMA({ halfLifeMs: 2, clock }); + shouldBe = 1; + e.insert(1); + expect(e.value()).toBe(shouldBe); + NOW += 2; + + for (let i = 2; i < 100; i++, NOW += 2) { + shouldBe = shouldBe * 0.5 + i * 0.5; + e.insert(i); + expect(e.value()).toBe(shouldBe); + } + }); + }); + + describe('Decay Calculation', () => { + it('affects EWMA value correctly over time', () => { + const clock = { now: vi.fn(() => 1000) }; // Mock time starts at 1000 + const ewma = new EWMA({ halfLifeMs: 5000, initialValue: 50, clock }); + + clock.now.mockReturnValue(2000); // Advance time by 1000ms + ewma.insert(100); // Insert a higher value to see decay effect + expect(ewma.value()).toBeLessThan(75); // Expect some decay towards the new value + + clock.now.mockReturnValue(7000); // Advance time significantly to test decay + expect(ewma.value()).toBeLessThan(100); // Ensure value continues to decay towards most recent insert + }); + }); + + describe('Custom Clock Functionality', () => { + it('uses custom clock correctly', () => { + let mockTime = 1000; + const clock = { now: () => mockTime }; + const ewma = new EWMA({ halfLifeMs: 10000, clock }); + ewma.insert(10); + mockTime += 5000; // Simulate passing of time + ewma.insert(20); + expect(ewma.value()).toBeCloseTo(6.33, 2); + }); + }); + + describe('Reset Functionality', () => { + it('correctly restarts calculation with various values', () => { + const ewma = new EWMA(); + ewma.reset(100); + expect(ewma.value()).toBe(100); + ewma.reset(-50); + expect(ewma.value()).toBe(-50); + }); + }); +}); diff --git a/packages/ui/components/ui/block-sync-status/ewma.ts b/packages/ui/components/ui/block-sync-status/ewma.ts index d6f0056a99..0ff09929a4 100644 --- a/packages/ui/components/ui/block-sync-status/ewma.ts +++ b/packages/ui/components/ui/block-sync-status/ewma.ts @@ -9,22 +9,38 @@ * adapt quickly to changes. The half-life parameter controls how quickly the weights decrease for * older values. */ + +interface EWMAProps { + halfLifeMs?: number; + initialValue?: number | undefined; + clock?: Clock; +} + +interface Clock { + now: () => number; +} + export class EWMA { private readonly decay: number; private ewma: number; private lastUpdate: number; + private clock: Clock; /** * Initializes a new instance of the Ewma class. + * * @param halfLifeMs The half-life in milliseconds, indicating how quickly the influence of a value * decreases to half its original weight. This parameter directly impacts the speed of convergence * towards recent values, with lower values making the average more sensitive to recent changes. * @param initialValue The initial value of the EWMA, defaulted to 0 if not provided. + * @param clock A function with a now() method. Defaults to Date. Helpful with testing. */ - constructor(halfLifeMs = 10_000, initialValue = 0) { + + constructor({ halfLifeMs = 10_000, initialValue = undefined, clock = Date }: EWMAProps = {}) { this.decay = halfLifeMs; - this.ewma = initialValue; - this.lastUpdate = Date.now(); + this.ewma = initialValue ?? 0; + this.clock = clock; + this.lastUpdate = initialValue ? clock.now() : 0; } /** @@ -32,10 +48,20 @@ export class EWMA { * since the last update and the new value's weight. */ insert(x: number): void { - const now = Date.now(); + const now = this.clock.now(); const elapsed = now - this.lastUpdate; this.lastUpdate = now; + // This seemingly magic equation is derived from the fact that we are + // defining a half life for each value. A half life is the amount of time + // that it takes for a value V to decay to .5V or V/2. Elapsed is the time + // delta between this value being reported and the previous value being + // reported. Given the half life, and the amount of time since the last + // reported value, this equation determines how much the new value should + // be represented in the ewma. + // For a detailed proof read: + // A Framework for the Analysis of Unevenly Spaced Time Series Data + // Eckner, 2014 const weight = Math.pow(2, -elapsed / this.decay); this.ewma = weight * this.ewma + (1 - weight) * x; } @@ -45,7 +71,7 @@ export class EWMA { * new value was the first and only value provided. */ reset(x: number): void { - this.lastUpdate = Date.now(); + this.lastUpdate = this.clock.now(); this.ewma = x; } diff --git a/packages/ui/components/ui/block-sync-status/index.tsx b/packages/ui/components/ui/block-sync-status/index.tsx index 170f21f3e0..cf906ff70d 100644 --- a/packages/ui/components/ui/block-sync-status/index.tsx +++ b/packages/ui/components/ui/block-sync-status/index.tsx @@ -10,7 +10,9 @@ interface BlockSyncProps { } export const BlockSyncStatus = ({ lastBlockHeight, lastBlockSynced }: BlockSyncProps) => { - const { formattedTimeRemaining, confident } = useSyncProgress(lastBlockSynced, lastBlockHeight); + if (!lastBlockHeight) return
; + + const isSyncing = lastBlockHeight - lastBlockSynced > 10; return ( - {lastBlockHeight ? ( - // Is syncing ⏳ - lastBlockHeight - lastBlockSynced > 10 ? ( - <> -
-

Syncing blocks...

-
- -

- {lastBlockSynced}/{lastBlockHeight} -

-

- {formattedTimeRemaining} -

- - ) : ( - // Is synced ✅ - <> -
-
-

Blocks synced

- -
-
- -

- {lastBlockSynced}/{lastBlockHeight} -

- - ) - ) : null} + {isSyncing ? ( + + ) : ( + + )}
); }; + +const SyncingState = ({ lastBlockHeight, lastBlockSynced }: BlockSyncProps) => { + const { formattedTimeRemaining, confident } = useSyncProgress(lastBlockSynced, lastBlockHeight); + + return ( + <> +
+

Syncing blocks...

+
+ +

+ {lastBlockSynced}/{lastBlockHeight} +

+

+ {formattedTimeRemaining} +

+ + ); +}; + +const FullySyncedState = ({ lastBlockHeight, lastBlockSynced }: BlockSyncProps) => { + return ( + <> +
+
+

Blocks synced

+ +
+
+ +

+ {lastBlockSynced}/{lastBlockHeight} +

+ + ); +}; diff --git a/packages/ui/components/ui/block-sync-status/sync-progress-hook.ts b/packages/ui/components/ui/block-sync-status/sync-progress-hook.ts index ba07a9447b..51f157e19b 100644 --- a/packages/ui/components/ui/block-sync-status/sync-progress-hook.ts +++ b/packages/ui/components/ui/block-sync-status/sync-progress-hook.ts @@ -8,8 +8,6 @@ import humanizeDuration from 'humanize-duration'; * to smooth the speed calculation. * * @returns An object containing: - * - speed: Current speed of synchronization in blocks per second. - * - timeRemaining: Estimated time remaining to complete the synchronization, in seconds. * - formattedTimeRemaining: Human-readable string representation (e.g., "13 min, 49 sec"). * - confident: A boolean flag indicating whether the speed calculation is considered reliable. */ @@ -52,5 +50,5 @@ export const useSyncProgress = ( const formattedTimeRemaining = timeRemaining === Infinity ? '' : humanizeDuration(timeRemaining * 1000, { round: true }); - return { speed, timeRemaining, formattedTimeRemaining, confident }; + return { formattedTimeRemaining, confident }; };