diff --git a/frontend/package.json b/frontend/package.json index c9f5218f60..7ade4400c2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "start": "npm install && ng serve --configuration local --open", "start:dev": "ng serve --configuration development", "build": "ng build", - "build:prod": "npm run prebuild && ng build --configuration production", + "build:prod": "ng build --configuration production", "tests": "npx cypress open --config baseUrl=http://localhost:4200", "tests:headless": "npx cypress run --headless --config baseUrl=http://localhost:4200", "docker": "npm run build:prod && docker buildx build --platform linux/amd64 -t openmina/frontend:latest . && docker push openmina/frontend:latest", diff --git a/frontend/src/app/core/helpers/file-progress.helper.ts b/frontend/src/app/core/helpers/file-progress.helper.ts new file mode 100644 index 0000000000..2e53a10f03 --- /dev/null +++ b/frontend/src/app/core/helpers/file-progress.helper.ts @@ -0,0 +1,99 @@ +import { BehaviorSubject } from 'rxjs'; + +class AssetMonitor { + readonly downloads: Map = new Map(); + readonly progress$: BehaviorSubject; + + constructor(progress$: BehaviorSubject) { + this.progress$ = progress$; + this.setupInterceptor(); + } + + private setupInterceptor(): void { + const originalFetch = window.fetch; + const self = this; + + window.fetch = async function (resource, options) { + // Only intercept asset requests (you can modify these extensions as needed) + const assetExtensions = ['.wasm']; + const isAsset = assetExtensions.some(ext => + resource.toString().toLowerCase().endsWith(ext), + ); + + if (!isAsset) { + return originalFetch(resource, options); + } + + const startTime = performance.now(); + const downloadInfo = { + url: resource.toString(), + startTime, + progress: 0, + totalSize: 0, + status: 'pending', + endTime: 0, + duration: 0, + }; + + self.downloads.set(resource.toString(), downloadInfo); + self.emitProgress(downloadInfo); + + try { + const response = await originalFetch(resource, options); + const reader = response.clone().body.getReader(); + const contentLength = +response.headers.get('Content-Length'); + downloadInfo.totalSize = contentLength; + let receivedLength = 0; + + while (true) { + try { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + receivedLength += value.length; + downloadInfo.progress = (receivedLength / contentLength) * 100; + self.emitProgress(downloadInfo); + } catch (error) { + downloadInfo.status = 'error'; + self.emitProgress(downloadInfo); + throw error; + } + } + + downloadInfo.status = 'complete'; + downloadInfo.endTime = performance.now(); + downloadInfo.duration = downloadInfo.endTime - downloadInfo.startTime; + self.emitProgress(downloadInfo); + return await response; + } catch (error_1) { + downloadInfo.status = 'error'; + self.emitProgress(downloadInfo); + throw error_1; + } + }; + } + + private emitProgress(downloadInfo: any): void { + this.progress$.next({ + url: downloadInfo.url, + progress: downloadInfo.progress.toFixed(2), + totalSize: downloadInfo.totalSize, + status: downloadInfo.status, + duration: downloadInfo.duration, + startTime: downloadInfo.startTime, + endTime: downloadInfo.endTime, + downloaded: downloadInfo.progress * downloadInfo.totalSize / 100, + }); + } +} + +export class FileProgressHelper { + static progress$: BehaviorSubject = new BehaviorSubject(null); + + static initDownloadProgress(): void { + new AssetMonitor(this.progress$); + } +} diff --git a/frontend/src/app/core/services/web-node.service.ts b/frontend/src/app/core/services/web-node.service.ts index 1394721876..b53ae44d16 100644 --- a/frontend/src/app/core/services/web-node.service.ts +++ b/frontend/src/app/core/services/web-node.service.ts @@ -1,10 +1,12 @@ -import { Injectable } from '@angular/core'; +import { Inject, Injectable } from '@angular/core'; import { BehaviorSubject, catchError, filter, from, fromEvent, map, merge, Observable, of, switchMap, tap } from 'rxjs'; import base from 'base-x'; import { any } from '@openmina/shared'; import { HttpClient } from '@angular/common/http'; import { sendSentryEvent } from '@shared/helpers/webnode.helper'; import { DashboardPeerStatus } from '@shared/types/dashboard/dashboard.peer'; +import { DOCUMENT } from '@angular/common'; +import { FileProgressHelper } from '@core/helpers/file-progress.helper'; @Injectable({ providedIn: 'root', @@ -18,7 +20,9 @@ export class WebNodeService { readonly webnodeProgress$: BehaviorSubject = new BehaviorSubject(''); - constructor(private http: HttpClient) { + constructor(private http: HttpClient, + @Inject(DOCUMENT) private document: Document) { + FileProgressHelper.initDownloadProgress(); const basex = base('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'); any(window)['bs58btc'] = { encode: (buffer: Uint8Array | number[]) => 'z' + basex.encode(buffer), @@ -26,7 +30,32 @@ export class WebNodeService { }; } + private loadWebnodeJs(): void { + if (this.document.querySelector('[data-webnode]')) { + return; + } + + const script = this.document.createElement('script'); + script.type = 'module'; + script.setAttribute('data-webnode', 'true'); + script.textContent = ` + import('./assets/webnode/pkg/openmina_node_web.js') + .then(v => { + window.webnode = v; + window.dispatchEvent(new CustomEvent('webNodeLoaded')); + }) + .catch(er => { + if (window.env?.configs.some(c => c.isWebNode)) { + console.error('Failed to load Web Node:', er); + } + }); + `; + + this.document.body.appendChild(script); + } + loadWasm$(): Observable { + this.loadWebnodeJs(); sendSentryEvent('Loading WebNode JS'); return merge( of(any(window).webnode).pipe(filter(Boolean)), diff --git a/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.html b/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.html index ae60393a22..fffd2a9436 100644 --- a/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.html +++ b/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.html @@ -1,44 +1,51 @@ -
-
+
+ +
+
+
- With the Web Node, you can produce blocks directly through your browser + Produce blocks, +  right in your browser
-
+
@if (!loading[loading.length - 1].loaded) { - Setting up your in-browser Web Node... + @if (loading[0].status === WebNodeStepStatus.LOADING) { + Downloading... + } @else { + ~7 seconds left + } } @else { Web Node is ready }
-
+
+ +
@for (item of loading; track $index) { -
-
{{ item.name }}
-
- - {{ item.loaded ? 'task_alt' : 'more_horiz' }} - +
+ @if (item.status === WebNodeStepStatus.LOADING) { + + } @else { + check_circle + } +
{{ item.name }}
+ @if (item.data) { + @if (item.data.total) { + {{ item.data.downloaded }} of {{ item.data.total }} MB + } @else { + {{ item.data.est }} + } + }
}
-
- @if (ready) { - - } -
- @if (!loading[loading.length - 1].loaded && errors.length) {
}
+ diff --git a/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.scss b/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.scss index e403a607d1..0fb76960f3 100644 --- a/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.scss +++ b/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.scss @@ -10,7 +10,12 @@ $white: #000000; font-size: 16px; } +.logo-header { + height: 56px; +} + .data-wrapper { + height: calc(100% - 56px - 72px); max-width: 568px; .header { @@ -22,25 +27,41 @@ $white: #000000; } } + mina-loading-spinner { + margin: 0 2px; + } + + .mina-icon.circle-check { + font-variation-settings: 'FILL' 1, 'wght' 300 !important; + } + .mina-icon.aware-primary { animation: fadeIn 1500ms ease-in-out infinite; } + + .progress { + height: 380px; + margin: 64px 0; + } } -.launch { - height: 56px !important; - font-size: 20px; - width: 341px; - background: linear-gradient(to right, $green 0%, $sand 50%, $peach 100%); - background-size: 500%; - margin-top: 64px; - transition: background-position 1s ease; - background-position: 0 50%; - left: 50%; - transform: translateX(-50%); - - &:hover { - background-position: 100% 50%; +.footer { + height: 72px; + max-width: 568px; + + button { + width: 158px; + height: 48px !important; + background-color: $cta-container; + + &.disabled { + opacity: 0.25; + pointer-events: none; + } + + &:hover:not(.disabled) { + background-color: $selected-primary; + } } } @@ -58,3 +79,12 @@ $white: #000000; opacity: 1; } } + +@media (max-width: 768px) { + .font-16 { + font-size: 15px; + } + .loading-webnode span { + display: block; + } +} diff --git a/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.ts b/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.ts index c181de2b15..4d74dc10ac 100644 --- a/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.ts +++ b/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.ts @@ -1,19 +1,31 @@ -import { ChangeDetectionStrategy, Component, NgZone, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, NgZone, OnInit, ViewChild } from '@angular/core'; import { untilDestroyed } from '@ngneat/until-destroy'; import { StoreDispatcher } from '@shared/base-classes/store-dispatcher.class'; import { WebNodeService } from '@core/services/web-node.service'; import { GlobalErrorHandlerService } from '@openmina/shared'; -import { NgClass, NgForOf, NgIf } from '@angular/common'; +import { NgClass, NgForOf, NgIf, NgOptimizedImage } from '@angular/common'; import { Router } from '@angular/router'; import { getFirstFeature } from '@shared/constants/config'; import { trigger, state, style, transition, animate } from '@angular/animations'; -import { switchMap, timer } from 'rxjs'; +import { filter, switchMap, timer } from 'rxjs'; +import { LoadingSpinnerComponent } from '@shared/loading-spinner/loading-spinner.component'; +import { FileProgressHelper } from '@core/helpers/file-progress.helper'; +import * as d3 from 'd3'; -export interface WebNodeDemoLoadingStep { +export enum WebNodeStepStatus { + DONE, + LOADING, + PENDING, +} + +export interface WebNodeLoadingStep { name: string; loaded: boolean; - attempt?: number; - step: number; + status: WebNodeStepStatus; + data: { + downloaded: string; + total: string; + } | any; } @@ -28,6 +40,8 @@ export interface WebNodeDemoLoadingStep { NgClass, NgIf, NgForOf, + NgOptimizedImage, + LoadingSpinnerComponent, ], animations: [ trigger('fadeIn', [ @@ -41,40 +55,123 @@ export interface WebNodeDemoLoadingStep { }) export class WebNodeDemoDashboardComponent extends StoreDispatcher implements OnInit { - readonly loading: WebNodeDemoLoadingStep[] = [ - { name: 'Setting up browser for Web Node', loaded: false, step: 1 }, - { name: 'Getting ready to produce blocks', loaded: false, step: 2 }, - { name: 'Connecting directly to Mina network', loaded: false, step: 3 }, + protected readonly WebNodeStepStatus = WebNodeStepStatus; + readonly loading: WebNodeLoadingStep[] = [ + { name: 'Setting up browser for Web Node', loaded: false, status: WebNodeStepStatus.LOADING, data: null }, + { name: 'Getting ready to produce blocks', loaded: false, status: WebNodeStepStatus.PENDING, data: { est: '~5s' } }, + { + name: 'Connecting directly to Mina network', + loaded: false, + status: WebNodeStepStatus.PENDING, + data: { est: '~2s' }, + }, ]; ready: boolean = false; errors: string[] = []; + private stepsPercentages = this.stepPercentages; + private secondStepInterval: any; + private thirdStepInterval: any; + private progress: number = 0; + private svg: any; + private progressBar: any; + private arc: any; + @ViewChild('progress', { static: true }) private chartContainer: ElementRef; + constructor(private errorHandler: GlobalErrorHandlerService, private webNodeService: WebNodeService, - private zone: NgZone, private router: Router) { super(); } ngOnInit(): void { + this.fetchProgress(); this.listenToErrorIssuing(); this.checkWebNodeProgress(); this.fetchPeersInformation(); + this.buildProgressBar(); } private checkWebNodeProgress(): void { this.webNodeService.webnodeProgress$.pipe(untilDestroyed(this)).subscribe((state: string) => { if (state === 'Loaded') { this.loading[0].loaded = true; + this.loading[0].status = WebNodeStepStatus.DONE; + this.loading[1].status = WebNodeStepStatus.LOADING; + this.advanceProgressFor2ndStep(); } else if (state === 'Started') { + clearInterval(this.secondStepInterval); this.loading[0].loaded = true; this.loading[1].loaded = true; + this.loading[0].status = WebNodeStepStatus.DONE; + this.loading[1].status = WebNodeStepStatus.DONE; + this.loading[2].status = WebNodeStepStatus.LOADING; + this.updateProgressBar(this.stepsPercentages[0] + this.stepsPercentages[1]); + this.advanceProgressFor3rdStep(); } else if (state === 'Connected') { - this.loading.forEach((step: WebNodeDemoLoadingStep) => step.loaded = true); + clearInterval(this.thirdStepInterval); + this.loading[0].status = WebNodeStepStatus.DONE; + this.loading[1].status = WebNodeStepStatus.DONE; + this.loading[2].status = WebNodeStepStatus.DONE; + this.loading.forEach((step: WebNodeLoadingStep) => step.loaded = true); + this.goToEndProgress(); } - this.ready = this.loading.every((step: WebNodeDemoLoadingStep) => step.loaded); + this.ready = this.loading.every((step: WebNodeLoadingStep) => step.loaded); this.detect(); }); } + private get stepPercentages(): number[] { + // random between 65 and 80 + const first = Math.floor(Math.random() * 15) + 65; + // random between 15 and 17 + const second = Math.floor(Math.random() * 2) + 15; + const third = 100 - first - second; + return [first, second, third]; + } + + private advanceProgressFor2ndStep(): void { + // first step is done, now we are working on the second step. + // each second add 1% to the progress. + // but the second step is only 10% of the total progress. + // so never go above 10%. Stop at 9% if the second step is not done yet. + let progress = 0; + this.secondStepInterval = setInterval(() => { + if (progress < this.stepsPercentages[1] - 1) { + progress += 0.125; + this.updateProgressBar(this.stepsPercentages[0] + progress); + } + }, 75); + } + + private advanceProgressFor3rdStep(): void { + // second step is done, now we are working on the third step. + // each second add 1% to the progress. + // but the third step is only 10% of the total progress. + // so never go above 10%. Stop at 9% if the third step is not done yet. + let progress = 0; + this.thirdStepInterval = setInterval(() => { + if (progress < this.stepsPercentages[2] - 1) { + progress += 0.125; + this.updateProgressBar(this.stepsPercentages[0] + this.stepsPercentages[1] + progress); + } + }, 75); + } + + private goToEndProgress(): void { + // increase it to 100% to make sure it's done. + // make it smooth, do a lot of increases from where it left + // to make it look like it's finishing. + + let progress = this.progress; + let interval = setInterval(() => { + if (progress < 100) { + progress += 1; + this.updateProgressBar(progress); + } else { + clearInterval(interval); + } + }, 10); + } + private fetchPeersInformation(): void { timer(0, 1000).pipe( switchMap(() => this.webNodeService.peers$), @@ -90,10 +187,96 @@ export class WebNodeDemoDashboardComponent extends StoreDispatcher implements On }); } + private fetchProgress(): void { + FileProgressHelper.progress$.pipe( + filter(Boolean), + untilDestroyed(this), + ).subscribe((progress) => { + this.loading[0].data = { + downloaded: (progress.downloaded / 1e6).toFixed(1), + total: (progress.totalSize / 1e6).toFixed(1), + }; + // this step is only 1 out of 3. But it counts as 80% of the 100% progress. + // So we need to calculate the total progress based on the current step. + const totalProgress = (progress.progress * this.stepsPercentages[0]) / 100; + this.updateProgressBar(totalProgress); + this.detect(); + }); + } + goToDashboard(): void { if (!this.ready) { return; } this.router.navigate([getFirstFeature()]); } + + private buildProgressBar(): void { + const width = this.chartContainer.nativeElement.offsetWidth; + const height = this.chartContainer.nativeElement.offsetHeight; + const barWidth = 12; + const progress = 0; + + this.svg = d3 + .select(this.chartContainer.nativeElement) + .append('svg') + .attr('width', width) + .attr('height', height); + + this.progressBar = this.svg.append('g') + .attr('transform', `translate(${width / 2}, ${height / 2})`); + + const radius = Math.min(width, height) / 2 - barWidth; + this.arc = d3.arc() + .innerRadius(radius - barWidth) + .outerRadius(radius) + .startAngle(0) + .endAngle(Math.PI * 2 * (progress / 100)); + + this.progressBar.append('path') + .attr('d', this.arc) + .attr('opacity', 0.8) + .attr('fill', 'url(#progress-gradient)'); + + const defs = this.svg.append('defs'); + const gradient = defs.append('linearGradient') + .attr('id', 'progress-gradient') + .attr('x1', '0%') + .attr('x2', '100%') + .attr('y1', '0%') + .attr('y2', '0%') + .attr('gradientTransform', 'rotate(45)'); + + gradient.append('stop') + .attr('offset', '8%') + .attr('stop-color', '#57D7FF'); + gradient.append('stop') + .attr('offset', '60%') + .attr('stop-color', '#FDA2FF'); + gradient.append('stop') + .attr('offset', '100%') + .attr('stop-color', '#FF833D'); + + this.progressBar.append('text') + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'middle') + .attr('font-size', '40px') + .attr('fill', 'var(--base-primary)') + .attr('opacity', 0.8) + .text(`${(progress).toFixed(0)}%`); + } + + private updateProgressBar(newProgress: number): void { + if (newProgress > 100) { + newProgress = 100; + } + this.progress = newProgress; + this.arc.endAngle(Math.PI * 2 * (newProgress / 100)); + this.progressBar + .select('path') + .attr('d', this.arc); + this.progressBar + .select('text') + .text(`${(newProgress).toFixed(0)}%`); + } } diff --git a/frontend/src/app/layout/web-node-landing-page/web-node-landing-page.component.html b/frontend/src/app/layout/web-node-landing-page/web-node-landing-page.component.html index bf87cf028c..ae628b8723 100644 --- a/frontend/src/app/layout/web-node-landing-page/web-node-landing-page.component.html +++ b/frontend/src/app/layout/web-node-landing-page/web-node-landing-page.component.html @@ -4,7 +4,6 @@
-

With the Web Node, anyone can produce blocks directly through their browser

@@ -18,47 +17,7 @@

- -
-
diff --git a/frontend/src/app/layout/web-node-landing-page/web-node-landing-page.component.scss b/frontend/src/app/layout/web-node-landing-page/web-node-landing-page.component.scss index 1862257a3c..2b1f13cb31 100644 --- a/frontend/src/app/layout/web-node-landing-page/web-node-landing-page.component.scss +++ b/frontend/src/app/layout/web-node-landing-page/web-node-landing-page.component.scss @@ -82,36 +82,6 @@ $white: #000000; } } -/* -.card-list { - margin-top: 40px; - margin-bottom: 160px; - padding-left: 24px; - padding-right: 24px; - gap: 24px; - - .card-wrapper { - height: 216px; - max-width: 400px; - background: linear-gradient(148deg, rgba(#59bfb5, 0.3) 20%, rgba(#acdea0, 0.3), rgba(#acdea0, 0.1), rgba(100, 100, 100, 0.1) 70%, transparent 80%); - padding: 1px; - - .card { - padding: 24px; - - .mina-icon { - color: #59bfb5; - font-size: 32px; - } - - > * { - margin-bottom: 24px; - } - } - } -} -*/ - .hardcoded-key { background-color: $warn-container; color: $warn-primary; @@ -251,28 +221,6 @@ $white: #000000; .images .step .step-text { font-size: 4.2vw; } - /* - - .card-list { - padding-left: 6.667vw; - padding-right: 6.667vw; - margin-bottom: 17.778vw; - margin-top: 17.778vw; - - .card-wrapper { - min-height: 55.556vw; - max-width: 100vw; - - .card { - padding: 6.667vw 4.444vw 6.667vw 6.667vw; - - .mina-icon { - font-size: 8.889vw; - } - } - } - } - */ .hardcoded-key { font-size: 5vw; diff --git a/frontend/src/index.html b/frontend/src/index.html index d86532cf85..a722068238 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -45,18 +45,18 @@ // Get version from build-time replaced variable or from meta tag // This will be replaced during build (don't change this line!!) const WEBNODE_VERSION = '13b85f46d3496a8608a86c8af21374bf'; - const webNodeUrl = `./assets/webnode/pkg/openmina_node_web.js`; - - import(webNodeUrl) - .then((v) => { - window.webnode = v; - window.dispatchEvent(new CustomEvent('webNodeLoaded')); - }) - .catch((error) => { - if (window.env?.configs.some(c => c.isWebNode)) { - console.error('Failed to load Web Node:', error); - } - }); + // const webNodeUrl = `./assets/webnode/pkg/openmina_node_web.js`; + // + // import(webNodeUrl) + // .then((v) => { + // window.webnode = v; + // window.dispatchEvent(new CustomEvent('webNodeLoaded')); + // }) + // .catch((error) => { + // if (window.env?.configs.some(c => c.isWebNode)) { + // console.error('Failed to load Web Node:', error); + // } + // });