diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index fdd63676c1..06b79b0c3d 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1,7 +1,9 @@ @if (showLandingPage$ | async) { -} @else { +} @else if (showLoadingWebNodePage$ | async) { + +} @else if (loaded) { diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 2d36c7a388..8d44c2f904 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -5,9 +5,10 @@ import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout'; import { AppSelectors } from '@app/app.state'; import { AppActions } from '@app/app.actions'; import { filter, map, Observable, Subscription, take, timer } from 'rxjs'; -import { CONFIG, getFirstFeature } from '@shared/constants/config'; +import { CONFIG } from '@shared/constants/config'; import { StoreDispatcher } from '@shared/base-classes/store-dispatcher.class'; import { Router } from '@angular/router'; +import { Routes } from '@shared/enums/routes.enum'; @Component({ selector: 'app-root', @@ -20,8 +21,10 @@ export class AppComponent extends StoreDispatcher implements OnInit { protected readonly menu$: Observable = this.select$(AppSelectors.menu); protected readonly showLandingPage$: Observable = this.select$(getMergedRoute).pipe(filter(Boolean), map((route: MergedRoute) => route.url === '/')); + protected readonly showLoadingWebNodePage$: Observable = this.select$(getMergedRoute).pipe(filter(Boolean), map((route: MergedRoute) => route.url === `/${Routes.LOADING_WEB_NODE}`)); subMenusLength: number = 0; hideToolbar: boolean = CONFIG.hideToolbar; + loaded: boolean; private nodeUpdateSubscription: Subscription | null = null; @@ -42,10 +45,18 @@ export class AppComponent extends StoreDispatcher implements OnInit { take(1), filter((route: MergedRoute) => route.url !== '/'), ); + this.select( + getMergedRoute, + () => { + this.loaded = true; + this.detect(); + }, + filter(Boolean), + ); } goToWebNode(): void { - this.router.navigate([getFirstFeature()]); + this.router.navigate([Routes.LOADING_WEB_NODE]); this.initAppFunctionalities(); } diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index f2dbdf9b12..e63bed102f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -1,4 +1,4 @@ -import { APP_INITIALIZER, ErrorHandler, Injectable, LOCALE_ID, NgModule, Provider } from '@angular/core'; +import { APP_INITIALIZER, ErrorHandler, Injectable, LOCALE_ID, NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppComponent } from './app.component'; diff --git a/frontend/src/app/app.routing.ts b/frontend/src/app/app.routing.ts index ccf6903619..0a8cf9194a 100644 --- a/frontend/src/app/app.routing.ts +++ b/frontend/src/app/app.routing.ts @@ -14,6 +14,7 @@ export const SNARKS_TITLE: string = APP_TITLE + ' - Snarks'; export const BLOCK_PRODUCTION_TITLE: string = APP_TITLE + ' - Block Production'; export const MEMPOOL_TITLE: string = APP_TITLE + ' - Mempool'; export const BENCHMARKS_TITLE: string = APP_TITLE + ' - Benchmarks'; +export const WEBNODE_TITLE: string = APP_TITLE + ' - Web Node'; function generateRoutes(): Routes { @@ -64,6 +65,11 @@ function generateRoutes(): Routes { loadChildren: () => import('./features/benchmarks/benchmarks.module').then(m => m.BenchmarksModule), title: BENCHMARKS_TITLE, }, + { + path: 'loading-web-node', + loadChildren: () => import('./features/webnode/webnode.module').then(m => m.WebnodeModule), + title: WEBNODE_TITLE, + }, ]; if (CONFIG.showWebNodeLandingPage) { routes.push({ @@ -76,7 +82,7 @@ function generateRoutes(): Routes { ...routes, { path: '**', - redirectTo: getFirstFeature(), + redirectTo: CONFIG.showWebNodeLandingPage ? '' : getFirstFeature(), pathMatch: 'full', }, ]; diff --git a/frontend/src/app/core/services/web-node.service.ts b/frontend/src/app/core/services/web-node.service.ts index 612b29b6eb..1394721876 100644 --- a/frontend/src/app/core/services/web-node.service.ts +++ b/frontend/src/app/core/services/web-node.service.ts @@ -16,6 +16,8 @@ export class WebNodeService { private webNodeStartTime: number; private sentryEvents: any = {}; + readonly webnodeProgress$: BehaviorSubject = new BehaviorSubject(''); + constructor(private http: HttpClient) { const basex = base('123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'); any(window)['bs58btc'] = { @@ -45,6 +47,7 @@ export class WebNodeService { switchMap((wasm: any) => from(wasm.default('assets/webnode/pkg/openmina_node_web_bg.wasm')).pipe(map(() => wasm))), switchMap((wasm) => { sendSentryEvent('WebNode Wasm loaded. Starting WebNode'); + this.webnodeProgress$.next('Loaded'); return from(wasm.run(this.webNodeKeyPair.privateKey)); }), tap((webnode: any) => { @@ -52,6 +55,7 @@ export class WebNodeService { this.webNodeStartTime = Date.now(); (window as any)['webnode'] = webnode; this.webnode$.next(webnode); + this.webnodeProgress$.next('Started'); }), catchError((error) => { sendSentryEvent('WebNode failed to start'); @@ -83,7 +87,6 @@ export class WebNodeService { switchMap(handle => from(any(handle).state().peers())), tap((peers) => { if (!this.sentryEvents.sentNoPeersEvent && Date.now() - this.webNodeStartTime >= 5000 && peers.length === 0) { - console.log('WebNode has no peers after 5 seconds from startup.'); sendSentryEvent('WebNode has no peers after 5 seconds from startup.'); this.sentryEvents.sentNoPeersEvent = true; } @@ -96,6 +99,7 @@ export class WebNodeService { const seconds = (Date.now() - this.webNodeStartTime) / 1000; sendSentryEvent(`WebNode connected to its first peer after ${seconds}s`); this.sentryEvents.firstPeerConnected = true; + this.webnodeProgress$.next('Connected'); } }), ); 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 new file mode 100644 index 0000000000..ae60393a22 --- /dev/null +++ b/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.html @@ -0,0 +1,55 @@ +
+
+
+ With the Web Node, you can produce blocks directly through your browser +
+ +
+ @if (!loading[loading.length - 1].loaded) { + Setting up your in-browser Web Node... + } @else { + Web Node is ready + } +
+
+ +
+ @for (item of loading; track $index) { +
+
{{ item.name }}
+
+ + {{ item.loaded ? 'task_alt' : 'more_horiz' }} + +
+
+ } +
+ +
+ @if (ready) { + + } +
+ + @if (!loading[loading.length - 1].loaded && errors.length) { +
+
+
It appears there are some errors.
+
+
+ @for (error of errors; track $index) { +
{{ error }}
+ } +
+
+ } +
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 new file mode 100644 index 0000000000..e403a607d1 --- /dev/null +++ b/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.scss @@ -0,0 +1,60 @@ +@import 'openmina'; + +$green: #59bfb5; +$light-peach: #0d0d0d; +$peach: #acdea0; +$sand: #5bb3fb; +$white: #000000; + +.font-16 { + font-size: 16px; +} + +.data-wrapper { + max-width: 568px; + + .header { + margin-bottom: 56px; + + .loading-webnode { + font-size: 24px; + line-height: 32px; + } + } + + .mina-icon.aware-primary { + animation: fadeIn 1500ms ease-in-out infinite; + } +} + +.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%; + } +} + +@keyframes fadeIn { + 10% { + opacity: 1; + } + 38% { + opacity: 0.1; + } + 42% { + opacity: 0.1; + } + 70% { + opacity: 1; + } +} 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 new file mode 100644 index 0000000000..c181de2b15 --- /dev/null +++ b/frontend/src/app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component.ts @@ -0,0 +1,99 @@ +import { ChangeDetectionStrategy, Component, NgZone, OnInit } 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 { Router } from '@angular/router'; +import { getFirstFeature } from '@shared/constants/config'; +import { trigger, state, style, transition, animate } from '@angular/animations'; +import { switchMap, timer } from 'rxjs'; + +export interface WebNodeDemoLoadingStep { + name: string; + loaded: boolean; + attempt?: number; + step: number; +} + + +@Component({ + selector: 'mina-web-node-demo-dashboard', + templateUrl: './web-node-demo-dashboard.component.html', + styleUrls: ['./web-node-demo-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'flex-column h-100 w-100 align-center' }, + standalone: true, + imports: [ + NgClass, + NgIf, + NgForOf, + ], + animations: [ + trigger('fadeIn', [ + state('void', style({ opacity: 0 })), + state('*', style({ opacity: 1 })), + transition('void => *', [ + animate('.6s ease-in'), + ]), + ]), + ], +}) +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 }, + ]; + ready: boolean = false; + errors: string[] = []; + + constructor(private errorHandler: GlobalErrorHandlerService, + private webNodeService: WebNodeService, + private zone: NgZone, + private router: Router) { super(); } + + ngOnInit(): void { + this.listenToErrorIssuing(); + this.checkWebNodeProgress(); + this.fetchPeersInformation(); + } + + private checkWebNodeProgress(): void { + this.webNodeService.webnodeProgress$.pipe(untilDestroyed(this)).subscribe((state: string) => { + if (state === 'Loaded') { + this.loading[0].loaded = true; + } else if (state === 'Started') { + this.loading[0].loaded = true; + this.loading[1].loaded = true; + } else if (state === 'Connected') { + this.loading.forEach((step: WebNodeDemoLoadingStep) => step.loaded = true); + } + this.ready = this.loading.every((step: WebNodeDemoLoadingStep) => step.loaded); + this.detect(); + }); + } + + private fetchPeersInformation(): void { + timer(0, 1000).pipe( + switchMap(() => this.webNodeService.peers$), + untilDestroyed(this), + ).subscribe(); + } + + private listenToErrorIssuing(): void { + this.errorHandler.errors$ + .pipe(untilDestroyed(this)) + .subscribe((error: string) => { + console.log(error); + }); + } + + goToDashboard(): void { + if (!this.ready) { + return; + } + this.router.navigate([getFirstFeature()]); + } +} diff --git a/frontend/src/app/features/webnode/webnode.component.html b/frontend/src/app/features/webnode/webnode.component.html new file mode 100644 index 0000000000..98ac206ace --- /dev/null +++ b/frontend/src/app/features/webnode/webnode.component.html @@ -0,0 +1 @@ + diff --git a/frontend/src/app/features/webnode/webnode.component.scss b/frontend/src/app/features/webnode/webnode.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frontend/src/app/features/webnode/webnode.component.ts b/frontend/src/app/features/webnode/webnode.component.ts new file mode 100644 index 0000000000..676e16709d --- /dev/null +++ b/frontend/src/app/features/webnode/webnode.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { + WebNodeDemoDashboardComponent, +} from '@app/features/webnode/web-node-demo-dashboard/web-node-demo-dashboard.component'; + +@Component({ + selector: 'mina-webnode', + standalone: true, + imports: [ + WebNodeDemoDashboardComponent, + ], + templateUrl: './webnode.component.html', + styleUrl: './webnode.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WebnodeComponent { + +} diff --git a/frontend/src/app/features/webnode/webnode.module.ts b/frontend/src/app/features/webnode/webnode.module.ts new file mode 100644 index 0000000000..b9f10c30ef --- /dev/null +++ b/frontend/src/app/features/webnode/webnode.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { WebnodeRouting } from './webnode.routing'; + + +@NgModule({ + declarations: [], + imports: [ + CommonModule, + WebnodeRouting, + ], +}) +export class WebnodeModule {} diff --git a/frontend/src/app/features/webnode/webnode.routing.ts b/frontend/src/app/features/webnode/webnode.routing.ts new file mode 100644 index 0000000000..fc0e9b4e9d --- /dev/null +++ b/frontend/src/app/features/webnode/webnode.routing.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { WebnodeComponent } from '@app/features/webnode/webnode.component'; + +const routes: Routes = [ + { + path: '', + component: WebnodeComponent, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class WebnodeRouting {} 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 47a212ed2b..1862257a3c 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 @@ -74,7 +74,7 @@ $white: #000000; background: linear-gradient(to right, $green 0%, $sand 50%, $peach 100%); background-size: 500%; margin-bottom: 96px; - transition: background-position 2s ease; + transition: background-position 1s ease; background-position: 0 50%; &:hover { diff --git a/frontend/src/app/shared/enums/routes.enum.ts b/frontend/src/app/shared/enums/routes.enum.ts index f4a62ababd..cefbede581 100644 --- a/frontend/src/app/shared/enums/routes.enum.ts +++ b/frontend/src/app/shared/enums/routes.enum.ts @@ -37,4 +37,5 @@ export enum Routes { BLOCK_PRODUCTION = 'block-production', WON_SLOTS = 'won-slots', MEMPOOL = 'mempool', + LOADING_WEB_NODE = 'loading-web-node', } diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index 92de0479fe..c7e94f9576 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -39,22 +39,22 @@ export const environment: Readonly = { // name: 'Producer-2', // url: 'https://staging-devnet-openmina-bp-2-dashboard.minaprotocol.network', // }, - { - name: 'staging-devnet-bp-0', - url: 'https://staging-devnet-openmina-bp-0.minaprotocol.network', - }, - { - name: 'staging-devnet-bp-1', - url: 'https://staging-devnet-openmina-bp-1.minaprotocol.network', - }, - { - name: 'staging-devnet-bp-2', - url: 'https://staging-devnet-openmina-bp-2.minaprotocol.network', - }, - { - name: 'staging-devnet-bp-3', - url: 'https://staging-devnet-openmina-bp-3.minaprotocol.network', - }, + // { + // name: 'staging-devnet-bp-0', + // url: 'https://staging-devnet-openmina-bp-0.minaprotocol.network', + // }, + // { + // name: 'staging-devnet-bp-1', + // url: 'https://staging-devnet-openmina-bp-1.minaprotocol.network', + // }, + // { + // name: 'staging-devnet-bp-2', + // url: 'https://staging-devnet-openmina-bp-2.minaprotocol.network', + // }, + // { + // name: 'staging-devnet-bp-3', + // url: 'https://staging-devnet-openmina-bp-3.minaprotocol.network', + // }, { name: 'Web Node 1', isWebNode: true,