follow sideload progress (#2718)

* follow sideload progress

* small bugfix

* shareReplay with no refcount false

* don't wrap sideload progress in RPCResult

* dont present toast

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Matt Hill
2024-09-03 09:23:47 -06:00
committed by GitHub
parent 66b018a355
commit 9981ee7601
13 changed files with 200 additions and 125 deletions

View File

@@ -17,6 +17,7 @@ use rpc_toolkit::HandlerArgs;
use rustyline_async::ReadlineEvent;
use serde::{Deserialize, Serialize};
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
use tracing::instrument;
use ts_rs::TS;
@@ -188,7 +189,7 @@ pub async fn sideload(
SideloadParams { session }: SideloadParams,
) -> Result<SideloadResponse, Error> {
let (upload, file) = upload(&ctx, session.clone()).await?;
let (err_send, err_recv) = oneshot::channel();
let (err_send, err_recv) = oneshot::channel::<Error>();
let progress = Guid::new();
let progress_tracker = FullProgressTracker::new();
let mut progress_listener = progress_tracker.stream(Some(Duration::from_millis(200)));
@@ -202,12 +203,14 @@ pub async fn sideload(
use axum::extract::ws::Message;
async move {
if let Err(e) = async {
type RpcResponse = rpc_toolkit::yajrc::RpcResponse::<GenericRpcMethod<&'static str, (), FullProgress>>;
type RpcResponse = rpc_toolkit::yajrc::RpcResponse<
GenericRpcMethod<&'static str, (), FullProgress>,
>;
tokio::select! {
res = async {
while let Some(progress) = progress_listener.next().await {
ws.send(Message::Text(
serde_json::to_string(&RpcResponse::from_result::<RpcError>(Ok(progress)))
serde_json::to_string(&progress)
.with_kind(ErrorKind::Serialization)?,
))
.await
@@ -217,12 +220,8 @@ pub async fn sideload(
} => res?,
err = err_recv => {
if let Ok(e) = err {
ws.send(Message::Text(
serde_json::to_string(&RpcResponse::from_result::<RpcError>(Err(e)))
.with_kind(ErrorKind::Serialization)?,
))
.await
.with_kind(ErrorKind::Network)?;
ws.close_result(Err::<&str, _>(e.clone_output())).await?;
return Err(e)
}
}
}
@@ -260,7 +259,7 @@ pub async fn sideload(
}
.await
{
let _ = err_send.send(RpcError::from(e.clone_output()));
let _ = err_send.send(e.clone_output());
tracing::error!("Error sideloading package: {e}");
tracing::debug!("{e:?}");
}
@@ -407,19 +406,21 @@ pub async fn cli_install(
let mut progress = FullProgress::new();
type RpcResponse = rpc_toolkit::yajrc::RpcResponse<
GenericRpcMethod<&'static str, (), FullProgress>,
>;
loop {
tokio::select! {
msg = ws.next() => {
if let Some(msg) = msg {
if let Message::Text(t) = msg.with_kind(ErrorKind::Network)? {
progress =
serde_json::from_str::<RpcResponse>(&t)
.with_kind(ErrorKind::Deserialization)?.result?;
bar.update(&progress);
match msg.with_kind(ErrorKind::Network)? {
Message::Text(t) => {
progress =
serde_json::from_str::<FullProgress>(&t)
.with_kind(ErrorKind::Deserialization)?;
bar.update(&progress);
}
Message::Close(Some(c)) if c.code != CloseCode::Normal => {
return Err(Error::new(eyre!("{}", c.reason), ErrorKind::Network))
}
_ => (),
}
} else {
break;

View File

@@ -56,5 +56,6 @@ const routes: Routes = [
StatusComponentModule,
SharedPipesModule,
],
exports: [AppShowProgressComponent],
})
export class AppShowPageModule {}

View File

@@ -29,9 +29,7 @@ export class InitService extends Observable<MappedProgress> {
from(this.api.initGetProgress()),
).pipe(
switchMap(({ guid, progress }) =>
this.api
.openWebsocket$<T.FullProgress>(guid, {})
.pipe(startWith(progress)),
this.api.openWebsocket$<T.FullProgress>(guid).pipe(startWith(progress)),
),
map(({ phases, overall }) => {
return {

View File

@@ -38,7 +38,7 @@ export class LogsService extends Observable<readonly string[]> {
private readonly log$ = defer(() =>
this.api.initFollowLogs({ boot: 0 }),
).pipe(
switchMap(({ guid }) => this.api.openWebsocket$<Log>(guid, {})),
switchMap(({ guid }) => this.api.openWebsocket$<Log>(guid)),
bufferTime(500),
filter(logs => !!logs.length),
map(convertAnsi),

View File

@@ -5,6 +5,7 @@ import { SideloadPage } from './sideload.page'
import { Routes, RouterModule } from '@angular/router'
import { ExverPipesModule, SharedPipesModule } from '@start9labs/shared'
import { DragNDropDirective } from './dnd.directive'
import { InstallingProgressPipeModule } from 'src/app/pipes/install-progress/install-progress.module'
const routes: Routes = [
{
@@ -20,6 +21,7 @@ const routes: Routes = [
RouterModule.forChild(routes),
SharedPipesModule,
ExverPipesModule,
InstallingProgressPipeModule,
],
declarations: [SideloadPage, DragNDropDirective],
})

View File

@@ -7,92 +7,121 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-text-center with-widgets">
<ion-content class="ion-text-center ion-padding with-widgets">
<ng-container *ngIf="progress$ | async as progress; else noProgress">
<ng-container *ngFor="let phase of progress.phases">
<p>
{{ phase.name }}
<span *ngIf="phase.progress | installingProgress as progress">
: {{ progress }}%
</span>
</p>
<ion-progress-bar
[type]="
phase.progress === false ||
(phase.progress !== true &&
phase.progress !== null &&
!phase.progress.total)
? 'indeterminate'
: 'determinate'
"
[color]="phase.progress === true ? 'success' : 'secondary'"
[value]="(phase.progress | installingProgress) / 100"
></ion-progress-bar>
</ng-container>
</ng-container>
<!-- file upload -->
<div
*ngIf="!toUpload.file; else fileUploaded"
class="drop-area"
[class.drop-area_mobile]="isMobile"
appDnd
(onFileDropped)="handleFileDrop($event)"
>
<ion-icon
name="cloud-upload-outline"
color="dark"
style="font-size: 42px"
></ion-icon>
<h4>Upload .s9pk package file</h4>
<p *ngIf="onTor">
<ion-text color="success">
Tip: switch to LAN for faster uploads.
</ion-text>
</p>
<ion-button color="primary" type="file" class="ion-margin-top">
<label for="upload-photo">Browse</label>
<input
type="file"
style="position: absolute; opacity: 0; height: 100%"
id="upload-photo"
(change)="handleFileInput($event)"
/>
</ion-button>
</div>
<!-- file uploaded -->
<ng-template #fileUploaded>
<div class="drop-area_filled">
<h4>
<ion-icon
*ngIf="uploadState?.invalid"
name="close-circle-outline"
color="danger"
class="inline"
></ion-icon>
<ion-icon
*ngIf="!uploadState?.invalid"
class="inline"
name="checkmark-circle-outline"
color="success"
></ion-icon>
{{ uploadState?.message }}
</h4>
<div class="box" *ngIf="toUpload.icon && toUpload.manifest">
<div class="card">
<div class="row row_end">
<ion-button
style="
--background-hover: transparent;
--padding-end: 0px;
--padding-start: 0px;
"
fill="clear"
size="small"
(click)="clearToUpload()"
>
<ion-icon slot="icon-only" name="close" color="danger"></ion-icon>
</ion-button>
</div>
<div class="row">
<img
[alt]="toUpload.manifest.title + ' Icon'"
[src]="toUpload.icon | trustUrl"
/>
<h2>{{ toUpload.manifest.title }}</h2>
<p>{{ toUpload.manifest.version }}</p>
<ng-template #noProgress>
<div
*ngIf="!toUpload.file; else fileReady"
class="drop-area"
[class.drop-area_mobile]="isMobile"
appDnd
(onFileDropped)="handleFileDrop($event)"
>
<ion-icon
name="cloud-upload-outline"
color="dark"
style="font-size: 42px"
></ion-icon>
<h4>Upload .s9pk package file</h4>
<p *ngIf="onTor">
<ion-text color="success">
Tip: switch to LAN for faster uploads.
</ion-text>
</p>
<ion-button color="primary" type="file" class="ion-margin-top">
<label for="upload-photo">Browse</label>
<input
type="file"
style="position: absolute; opacity: 0; height: 100%"
id="upload-photo"
(change)="handleFileInput($event)"
/>
</ion-button>
</div>
<!-- file uploaded -->
<ng-template #fileReady>
<div class="drop-area_filled">
<h4>
<ion-icon
*ngIf="uploadState?.invalid"
name="close-circle-outline"
color="danger"
class="inline"
></ion-icon>
<ion-icon
*ngIf="!uploadState?.invalid"
class="inline"
name="checkmark-circle-outline"
color="success"
></ion-icon>
{{ uploadState?.message }}
</h4>
<div class="box" *ngIf="toUpload.icon && toUpload.manifest">
<div class="card">
<div class="row row_end">
<ion-button
style="
--background-hover: transparent;
--padding-end: 0px;
--padding-start: 0px;
"
fill="clear"
size="small"
(click)="clearToUpload()"
>
<ion-icon
slot="icon-only"
name="close"
color="danger"
></ion-icon>
</ion-button>
</div>
<div class="row">
<img
[alt]="toUpload.manifest.title + ' Icon'"
[src]="toUpload.icon | trustUrl"
/>
<h2>{{ toUpload.manifest.title }}</h2>
<p>{{ toUpload.manifest.version }}</p>
</div>
</div>
</div>
</div>
<ion-button
*ngIf="!toUpload.icon && !toUpload.manifest; else uploadButton"
color="primary"
(click)="clearToUpload()"
>
Try again
</ion-button>
<ng-template #uploadButton>
<ion-button color="primary" (click)="handleUpload()">
Upload & Install
<ion-button
*ngIf="!toUpload.icon && !toUpload.manifest; else uploadButton"
color="primary"
(click)="clearToUpload()"
>
Try again
</ion-button>
</ng-template>
</div>
<ng-template #uploadButton>
<ion-button color="primary" (click)="handleUpload()">
Upload & Install
</ion-button>
</ng-template>
</div>
</ng-template>
</ng-template>
</ion-content>

View File

@@ -1,10 +1,12 @@
import { Component } from '@angular/core'
import { isPlatform, NavController } from '@ionic/angular'
import { isPlatform } from '@ionic/angular'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { S9pk, T } from '@start9labs/start-sdk'
import { S9pk } from '@start9labs/start-sdk'
import cbor from 'cbor'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { SideloadService } from './sideload.service'
import { firstValueFrom } from 'rxjs'
interface Positions {
[key: string]: [bigint, bigint] // [position, length]
@@ -36,12 +38,14 @@ export class SideloadPage {
message: string
}
readonly progress$ = this.sideloadService.progress$
constructor(
private readonly loader: LoadingService,
private readonly api: ApiService,
private readonly navCtrl: NavController,
private readonly errorService: ErrorService,
private readonly config: ConfigService,
private readonly sideloadService: SideloadService,
) {}
handleFileDrop(e: any) {
@@ -111,15 +115,15 @@ export class SideloadPage {
}
async handleUpload() {
const loader = this.loader.open('Uploading package').subscribe()
const loader = this.loader.open('Starting upload').subscribe()
try {
const res = await this.api.sideloadPackage()
this.sideloadService.followProgress(res.progress)
this.api
.uploadPackage(res.upload, this.toUpload.file!)
.catch(e => console.error(e))
this.navCtrl.navigateRoot('/services')
await firstValueFrom(this.sideloadService.websocketConnected$)
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { endWith, ReplaySubject, shareReplay, Subject, switchMap } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Injectable({
providedIn: 'root',
})
export class SideloadService {
private readonly guid$ = new Subject<string>()
readonly websocketConnected$ = new ReplaySubject()
readonly progress$ = this.guid$.pipe(
switchMap(guid =>
this.api
.openWebsocket$<T.FullProgress>(guid, {
openObserver: {
next: () => this.websocketConnected$.next(''),
},
})
.pipe(endWith(null)),
),
shareReplay(1),
)
constructor(private readonly api: ApiService) {}
followProgress(guid: string) {
this.guid$.next(guid)
}
}

View File

@@ -12,7 +12,7 @@ export abstract class ApiService {
// http
// for sideloading packages
abstract uploadPackage(guid: string, body: Blob): Promise<string>
abstract uploadPackage(guid: string, body: Blob): Promise<void>
// for getting static files: ex icons, instructions, licenses
abstract getStaticProxy(
@@ -29,7 +29,7 @@ export abstract class ApiService {
abstract openWebsocket$<T>(
guid: string,
config: RR.WebsocketConfig<T>,
config?: RR.WebsocketConfig<T>,
): Observable<T>
// state

View File

@@ -43,12 +43,11 @@ export class LiveApiService extends ApiService {
// for sideloading packages
async uploadPackage(guid: string, body: Blob): Promise<string> {
return this.httpRequest({
async uploadPackage(guid: string, body: Blob): Promise<void> {
await this.httpRequest({
method: Method.POST,
body,
url: `/rest/rpc/${guid}`,
responseType: 'text',
})
}
@@ -86,7 +85,7 @@ export class LiveApiService extends ApiService {
openWebsocket$<T>(
guid: string,
config: RR.WebsocketConfig<T>,
config: RR.WebsocketConfig<T> = {},
): Observable<T> {
const { location } = this.document.defaultView!
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'

View File

@@ -81,9 +81,8 @@ export class MockApiService extends ApiService {
.subscribe()
}
async uploadPackage(guid: string, body: Blob): Promise<string> {
async uploadPackage(guid: string, body: Blob): Promise<void> {
await pauseFor(2000)
return 'success'
}
async getStaticProxy(
@@ -106,7 +105,7 @@ export class MockApiService extends ApiService {
openWebsocket$<T>(
guid: string,
config: RR.WebsocketConfig<T>,
config: RR.WebsocketConfig<T> = {},
): Observable<T> {
if (guid === 'db-guid') {
return this.mockWsSource$.pipe<any>(
@@ -125,6 +124,11 @@ export class MockApiService extends ApiService {
return from(this.initProgress()).pipe(
startWith(PROGRESS),
) as Observable<T>
} else if (guid === 'sideload-progress-guid') {
config.openObserver?.next(new Event(''))
return from(this.initProgress()).pipe(
startWith(PROGRESS),
) as Observable<T>
} else {
throw new Error('invalid guid type')
}
@@ -1079,8 +1083,8 @@ export class MockApiService extends ApiService {
async sideloadPackage(): Promise<RR.SideloadPackageRes> {
await pauseFor(2000)
return {
upload: '4120e092-05ab-4de2-9fbd-c3f1f4b1df9e', // no significance, randomly generated
progress: '5120e092-05ab-4de2-9fbd-c3f1f4b1df9e', // no significance, randomly generated
upload: 'sideload-upload-guid', // no significance, randomly generated
progress: 'sideload-progress-guid', // no significance, randomly generated
}
}

View File

@@ -285,7 +285,12 @@ export class MarketplaceService implements AbstractMarketplaceService {
this.api.getRegistryPackage(url, id, version ? `=${version}` : null),
).pipe(
map(pkgInfo =>
this.convertToMarketplacePkg(id, version, flavor, pkgInfo),
this.convertToMarketplacePkg(
id,
version === '*' ? null : version,
flavor,
pkgInfo,
),
),
)
}

View File

@@ -33,7 +33,7 @@ export class PatchDbSource extends Observable<Update<DataModel>[]> {
private readonly stream$ = inject(AuthService).isVerified$.pipe(
switchMap(verified => (verified ? this.api.subscribeToPatchDB({}) : EMPTY)),
switchMap(({ dump, guid }) =>
this.api.openWebsocket$<Revision>(guid, {}).pipe(
this.api.openWebsocket$<Revision>(guid).pipe(
bufferTime(250),
filter(revisions => !!revisions.length),
startWith([dump]),